@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.
- package/README.md +2 -411
- package/dist/browser.d.mts +2 -0
- package/dist/browser.mjs +1 -0
- package/dist/index.d.mts +2 -1248
- package/dist/index.mjs +54 -3557
- package/dist/server-data-DGbiKzMS.d.mts +1249 -0
- package/dist/start-app-BdXBCcor.mjs +2 -0
- package/docs/01-getting-started.md +230 -0
- package/docs/02-routing-and-controllers.md +197 -0
- package/docs/03-middleware.md +214 -0
- package/docs/04-rendering-and-hydration.md +271 -0
- package/docs/05-i18n.md +243 -0
- package/docs/06-http-client.md +286 -0
- package/docs/07-di-container.md +264 -0
- package/docs/08-observability.md +290 -0
- package/docs/09-server-and-deployment.md +242 -0
- package/docs/10-features-platform-pwa.md +238 -0
- package/docs/README.md +72 -0
- package/docs/advanced/custom-action-handler.md +248 -0
- package/docs/advanced/custom-adapter.md +264 -0
- package/docs/advanced/custom-event-recorder.md +318 -0
- package/docs/advanced/inline-proxy-codegen.md +200 -0
- package/docs/advanced/multi-tenant-scopes.md +330 -0
- package/docs/engineering/ci-release-flow.md +244 -0
- package/docs/engineering/project-structure.md +296 -0
- package/docs/engineering/testing.md +317 -0
- package/docs/pitfalls/container-scope-leak.md +215 -0
- package/docs/pitfalls/i18n-bundle-size.md +182 -0
- package/docs/pitfalls/proxy-binary-payloads.md +133 -0
- package/docs/pitfalls/redirect-vs-rewrite.md +147 -0
- package/docs/pitfalls/ssr-hydration-mismatch.md +163 -0
- package/docs/pitfalls/ssr-vs-csr-globals.md +176 -0
- package/docs/zh/01-getting-started.md +230 -0
- package/docs/zh/02-routing-and-controllers.md +197 -0
- package/docs/zh/03-middleware.md +214 -0
- package/docs/zh/04-rendering-and-hydration.md +271 -0
- package/docs/zh/05-i18n.md +243 -0
- package/docs/zh/06-http-client.md +286 -0
- package/docs/zh/07-di-container.md +264 -0
- package/docs/zh/08-observability.md +287 -0
- package/docs/zh/09-server-and-deployment.md +242 -0
- package/docs/zh/10-features-platform-pwa.md +238 -0
- package/docs/zh/README.md +72 -0
- package/docs/zh/advanced/custom-action-handler.md +248 -0
- package/docs/zh/advanced/custom-adapter.md +264 -0
- package/docs/zh/advanced/custom-event-recorder.md +318 -0
- package/docs/zh/advanced/inline-proxy-codegen.md +200 -0
- package/docs/zh/advanced/multi-tenant-scopes.md +330 -0
- package/docs/zh/engineering/ci-release-flow.md +244 -0
- package/docs/zh/engineering/project-structure.md +296 -0
- package/docs/zh/engineering/testing.md +317 -0
- package/docs/zh/pitfalls/container-scope-leak.md +215 -0
- package/docs/zh/pitfalls/i18n-bundle-size.md +182 -0
- package/docs/zh/pitfalls/proxy-binary-payloads.md +133 -0
- package/docs/zh/pitfalls/redirect-vs-rewrite.md +147 -0
- package/docs/zh/pitfalls/ssr-hydration-mismatch.md +163 -0
- package/docs/zh/pitfalls/ssr-vs-csr-globals.md +176 -0
- package/package.json +12 -3
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# 3. Middleware
|
|
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.
|
|
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
|
|
160
|
+
|
|
161
|
+
```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
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
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
|
+
## Next
|
|
212
|
+
|
|
213
|
+
- [Rendering & hydration](./04-rendering-and-hydration.md) — what happens between `afterLoad` and HTML output
|
|
214
|
+
- [Pitfalls: redirect vs rewrite](./pitfalls/redirect-vs-rewrite.md) — choosing between the two
|
|
@@ -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
|