@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,230 @@
1
+ # 1. Getting started
2
+
3
+ A working `@finesoft/front` app in five minutes. By the end you have a Vue/React/Svelte page rendering on the server, hydrating in the browser, and ready to deploy.
4
+
5
+ ## Prerequisites
6
+
7
+ - Node.js `>= 22.12.0`
8
+ - A package manager: pnpm (recommended), npm, or yarn
9
+ - A view layer (Vue 3, React 19, or Svelte 5) — these docs use Vue, but every example translates one-to-one
10
+
11
+ ## Scaffold a new app
12
+
13
+ ```bash
14
+ npx @finesoft/create-app my-app
15
+ cd my-app
16
+ pnpm install
17
+ pnpm dev
18
+ ```
19
+
20
+ The scaffolder generates a runnable app with routing, SSR, and proxy already wired. Open <http://localhost:5173>.
21
+
22
+ If you want to understand what was generated, the rest of this page builds the same setup from scratch.
23
+
24
+ ## Add to an existing project
25
+
26
+ ```bash
27
+ pnpm add @finesoft/front
28
+ pnpm add -D vite hono
29
+ ```
30
+
31
+ Peer dependencies: `hono >= 4.0.0`. Optional but recommended: `@hono/node-server` for Node deployment, `vite >= 5.0.0` for the dev server.
32
+
33
+ ## Minimal project layout
34
+
35
+ ```
36
+ my-app/
37
+ ├── src/
38
+ │ ├── bootstrap.ts # routes + controllers (shared SSR + CSR)
39
+ │ ├── main.ts # browser entry
40
+ │ ├── ssr.ts # SSR entry
41
+ │ ├── App.vue # root component
42
+ │ └── lib/
43
+ │ └── controllers/
44
+ │ └── home.ts
45
+ ├── index.html
46
+ ├── vite.config.ts
47
+ ├── package.json
48
+ └── tsconfig.json
49
+ ```
50
+
51
+ ## Vite config
52
+
53
+ ```ts
54
+ // vite.config.ts
55
+ import { finesoftFrontViteConfig } from "@finesoft/front";
56
+ import vue from "@vitejs/plugin-vue";
57
+ import { defineConfig } from "vite";
58
+
59
+ export default defineConfig({
60
+ plugins: [
61
+ vue(),
62
+ finesoftFrontViteConfig({
63
+ ssr: { entry: "src/ssr.ts" },
64
+ i18n: { messagesDir: "src/locales" },
65
+ adapter: "auto",
66
+ }),
67
+ ],
68
+ });
69
+ ```
70
+
71
+ What `finesoftFrontViteConfig` adds:
72
+
73
+ - A Hono-based dev server that runs your SSR entry on every request
74
+ - Locale JSON auto-loading from `messagesDir`
75
+ - A build pipeline that emits both the client bundle and a server entry
76
+ - Platform adapter wiring for `adapter: "auto" | "node" | "vercel" | "cloudflare" | "netlify" | "static"`
77
+
78
+ ## Bootstrap (shared by SSR and CSR)
79
+
80
+ ```ts
81
+ // src/bootstrap.ts
82
+ import { type Framework, defineRoutes } from "@finesoft/front";
83
+ import { HomeController } from "./lib/controllers/home";
84
+
85
+ export function bootstrap(framework: Framework): void {
86
+ defineRoutes(framework, [{ path: "/", intentId: "home", controller: new HomeController() }]);
87
+ }
88
+ ```
89
+
90
+ The same `bootstrap()` runs on both sides. This is what guarantees the server and browser resolve URLs identically.
91
+
92
+ ## Controller
93
+
94
+ ```ts
95
+ // src/lib/controllers/home.ts
96
+ import { BaseController, type Container } from "@finesoft/front";
97
+
98
+ interface HomePage {
99
+ kind: "home";
100
+ title: string;
101
+ items: string[];
102
+ }
103
+
104
+ export class HomeController extends BaseController<Record<string, string>, HomePage> {
105
+ readonly intentId = "home";
106
+
107
+ async execute(_params: Record<string, string>, _container: Container): Promise<HomePage> {
108
+ return {
109
+ kind: "home",
110
+ title: "Welcome",
111
+ items: ["one", "two", "three"],
112
+ };
113
+ }
114
+
115
+ fallback(_params: Record<string, string>, error: unknown): HomePage {
116
+ return { kind: "home", title: "Failed", items: [] };
117
+ }
118
+ }
119
+ ```
120
+
121
+ `BaseController` wraps `execute()` in `try/catch` and calls `fallback()` on error. Always provide a `fallback` — see [error handling](./08-observability.md#error-handling-via-fallback) for why.
122
+
123
+ ## SSR entry
124
+
125
+ ```ts
126
+ // src/ssr.ts
127
+ import { createSSRRender, serializeServerData } from "@finesoft/front";
128
+ import { createSSRApp } from "vue";
129
+ import { renderToString } from "vue/server-renderer";
130
+ import App from "./App.vue";
131
+ import { bootstrap } from "./bootstrap";
132
+
133
+ export const render = createSSRRender({
134
+ bootstrap,
135
+ getErrorPage: () => ({ kind: "error", title: "Error" }),
136
+ async renderApp(page) {
137
+ const html = await renderToString(createSSRApp(App, { page }));
138
+ return { html, head: `<title>${(page as { title: string }).title}</title>`, css: "" };
139
+ },
140
+ });
141
+
142
+ export { serializeServerData };
143
+ ```
144
+
145
+ `createSSRRender` returns a function that takes a URL and returns rendered HTML + serialized prefetched intent data. The Vite plugin and adapters call it for you — you do not invoke it directly.
146
+
147
+ ## Browser entry
148
+
149
+ ```ts
150
+ // src/main.ts
151
+ import { startBrowserApp } from "@finesoft/front/browser";
152
+ import { createSSRApp } from "vue";
153
+ import App from "./App.vue";
154
+ import { bootstrap } from "./bootstrap";
155
+
156
+ startBrowserApp({
157
+ bootstrap,
158
+ mount(target, { framework }) {
159
+ const app = createSSRApp(App, { framework });
160
+ app.mount(target);
161
+ },
162
+ });
163
+ ```
164
+
165
+ `startBrowserApp` reads the SSR-injected `PrefetchedIntents` from the DOM, creates the `Framework`, runs the same `bootstrap()` as the server, and triggers the first page. By the time `mount()` runs the framework already has the initial `Page` ready.
166
+
167
+ Import from `@finesoft/front/browser` (not `@finesoft/front`) on the client to avoid pulling server-only modules into your client bundle.
168
+
169
+ ## Root component (Vue example)
170
+
171
+ ```vue
172
+ <!-- src/App.vue -->
173
+ <script setup lang="ts">
174
+ import { computed } from "vue";
175
+ import type { Framework } from "@finesoft/front";
176
+
177
+ const props = defineProps<{ framework?: Framework; page?: { title: string; items: string[] } }>();
178
+
179
+ // SSR receives `page` directly; CSR pulls the current page from the framework.
180
+ const page = computed(() => props.page ?? props.framework?.getCurrentPage());
181
+ </script>
182
+
183
+ <template>
184
+ <main>
185
+ <h1>{{ page?.title }}</h1>
186
+ <ul>
187
+ <li v-for="item in page?.items" :key="item">{{ item }}</li>
188
+ </ul>
189
+ </main>
190
+ </template>
191
+ ```
192
+
193
+ ## index.html
194
+
195
+ ```html
196
+ <!doctype html>
197
+ <html lang="en">
198
+ <head>
199
+ <meta charset="UTF-8" />
200
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
201
+ <!--head-->
202
+ </head>
203
+ <body>
204
+ <div id="app"><!--ssr--></div>
205
+ <script type="module" src="/src/main.ts"></script>
206
+ </body>
207
+ </html>
208
+ ```
209
+
210
+ The `<!--head-->` and `<!--ssr-->` placeholders are where the framework injects the SSR head fragment and rendered HTML. The placeholder names are exported as `SSR_PLACEHOLDERS` if you need them.
211
+
212
+ ## Run it
213
+
214
+ ```bash
215
+ pnpm dev # development with HMR
216
+ pnpm build # production build
217
+ pnpm preview # serve the production build locally
218
+ ```
219
+
220
+ Done. You have an app that:
221
+
222
+ - Renders on the server with prefetched data
223
+ - Hydrates without an extra fetch (SSR data is reused via `PrefetchedIntents`)
224
+ - Routes client-side without page reloads
225
+ - Is ready to deploy to any of the supported adapters
226
+
227
+ ## Next
228
+
229
+ - [Routing & controllers](./02-routing-and-controllers.md) — define more routes and control how they render
230
+ - [Project structure](./engineering/project-structure.md) — recommended layout once the app grows past a handful of pages
@@ -0,0 +1,203 @@
1
+ # 2. Routing & controllers
2
+
3
+ The framework's routing layer maps URLs to **intents**, intents to **controllers**, and controllers produce **pages**. This chapter covers all three.
4
+
5
+ ## The mental model
6
+
7
+ ```
8
+ URL ──Router.resolve()──▶ RouteMatch { intent, renderMode, guards }
9
+
10
+
11
+ IntentDispatcher.dispatch(intent)
12
+
13
+
14
+ Controller.execute() → Page
15
+ ```
16
+
17
+ A route definition combines:
18
+
19
+ - A **path pattern** (`/products/:id`)
20
+ - An **intent id** (logical name for the operation; one intent can have multiple routes)
21
+ - A **controller instance** (where the page data is produced)
22
+ - An optional **render mode** (`ssr` / `csr` / `prerender`)
23
+ - Optional **guards** (`beforeLoad` / `afterLoad`)
24
+
25
+ ## Defining routes
26
+
27
+ ```ts
28
+ // src/bootstrap.ts
29
+ import { type Framework, defineRoutes } from "@finesoft/front";
30
+ import { HomeController } from "./lib/controllers/home";
31
+ import { ProductController } from "./lib/controllers/product";
32
+ import { authGuard } from "./lib/guards/auth";
33
+
34
+ export function bootstrap(framework: Framework): void {
35
+ defineRoutes(framework, [
36
+ // Plain SSR route
37
+ { path: "/", intentId: "home", controller: new HomeController() },
38
+
39
+ // Dynamic segment
40
+ { path: "/products/:id", intentId: "product", controller: new ProductController() },
41
+
42
+ // CSR-only (server returns an empty shell)
43
+ {
44
+ path: "/dashboard",
45
+ intentId: "dashboard",
46
+ controller: new DashboardController(),
47
+ renderMode: "csr",
48
+ },
49
+
50
+ // Statically prerendered at build time
51
+ {
52
+ path: "/about",
53
+ intentId: "about",
54
+ controller: new AboutController(),
55
+ renderMode: "prerender",
56
+ },
57
+
58
+ // Protected route — reuses the home intent but gates with a guard
59
+ {
60
+ path: "/admin",
61
+ intentId: "home",
62
+ controller: new HomeController(),
63
+ beforeLoad: [authGuard],
64
+ },
65
+ ]);
66
+ }
67
+ ```
68
+
69
+ ### Route options
70
+
71
+ | Field | Type | Notes |
72
+ | ------------ | -------------------------------- | -------------------------------------------------------------------- |
73
+ | `path` | `string` | Path pattern with `:param` placeholders. Trailing `/` is normalized. |
74
+ | `intentId` | `string` | Logical operation name. Used to register the controller. |
75
+ | `controller` | `BaseController<TParams, TPage>` | Optional if the intent is already registered. |
76
+ | `renderMode` | `"ssr" \| "csr" \| "prerender"` | Default `"ssr"`. See [chapter 4](./04-rendering-and-hydration.md). |
77
+ | `beforeLoad` | `BeforeLoadGuard[]` | Run before the controller. See [chapter 3](./03-middleware.md). |
78
+ | `afterLoad` | `AfterLoadGuard[]` | Run after the page is produced. |
79
+
80
+ ### Path patterns
81
+
82
+ - Static: `/about`
83
+ - Parameterized: `/products/:id`, `/users/:userId/posts/:postId`
84
+ - Trailing wildcard: `/files/*`
85
+ - Optional segment is **not** supported — write two routes instead.
86
+
87
+ Parameters are passed to `controller.execute(params, container)` as a string-keyed object.
88
+
89
+ ## Writing a controller
90
+
91
+ ```ts
92
+ // src/lib/controllers/product.ts
93
+ import { BaseController, type Container, type HttpClient } from "@finesoft/front";
94
+
95
+ interface ProductPage {
96
+ kind: "product";
97
+ id: string;
98
+ name: string;
99
+ price: number;
100
+ }
101
+
102
+ export class ProductController extends BaseController<{ id: string }, ProductPage> {
103
+ readonly intentId = "product";
104
+
105
+ async execute(params: { id: string }, container: Container): Promise<ProductPage> {
106
+ const http = container.resolve<HttpClient>("http");
107
+ const product = await http.get<{ name: string; price: number }>(
108
+ `/api/products/${params.id}`,
109
+ );
110
+ return {
111
+ kind: "product",
112
+ id: params.id,
113
+ name: product.name,
114
+ price: product.price,
115
+ };
116
+ }
117
+
118
+ fallback(params: { id: string }, _error: unknown): ProductPage {
119
+ return { kind: "product", id: params.id, name: "Not available", price: 0 };
120
+ }
121
+ }
122
+ ```
123
+
124
+ ### Controller contract
125
+
126
+ | Member | Required | Purpose |
127
+ | ---------- | -------- | ---------------------------------------------------------------------------------- |
128
+ | `intentId` | yes | Must match the route's `intentId` (or the `IntentDispatcher.register` call). |
129
+ | `execute` | yes | Produce the page. Receives parsed path params and the request-scoped DI container. |
130
+ | `fallback` | yes | Return a degraded page when `execute()` throws. Must be synchronous and total. |
131
+
132
+ `BaseController` wraps `execute()` in `try/catch` and routes any error through `fallback()`. The framework never throws out of `dispatch()` — your `fallback()` is the last line of defense.
133
+
134
+ ### Why `fallback` is mandatory
135
+
136
+ A thrown error in `execute()` during SSR would otherwise crash the request and either 500 or render a blank document. `fallback()` lets you return a structured "error" `Page` that your view layer renders as a graceful failure (banner, retry button, etc.). See the [error handling section in observability](./08-observability.md#error-handling-via-fallback) for patterns.
137
+
138
+ ## Render modes
139
+
140
+ | Mode | Server returns | When to use |
141
+ | ------------- | -------------------------------------------- | -------------------------------------------------------- |
142
+ | `"ssr"` | Fully rendered HTML + serialized data | Default. Best for SEO and TTFB-sensitive pages. |
143
+ | `"csr"` | Empty shell HTML; controller runs in browser | Authenticated dashboards, heavy-personalization pages. |
144
+ | `"prerender"` | Static HTML built at deploy time | Marketing, docs, blog. Combine with ISR (see chapter 4). |
145
+
146
+ The mode is **per-route**, so you can mix freely. The framework rebuilds prerendered routes at build time; SSR routes execute on every request.
147
+
148
+ ## Registering controllers without routes
149
+
150
+ You can register a controller for an intent without exposing it as a route. This is useful for intents triggered only by `dispatchAction`:
151
+
152
+ ```ts
153
+ framework.intentDispatcher.register("checkout", new CheckoutController());
154
+
155
+ // Elsewhere:
156
+ const page = await framework.intentDispatcher.dispatch({
157
+ intentId: "checkout",
158
+ params: { cartId },
159
+ });
160
+ ```
161
+
162
+ Routes are simply intent dispatches keyed by URL.
163
+
164
+ ## One intent, many routes
165
+
166
+ Same intent can serve different URLs:
167
+
168
+ ```ts
169
+ defineRoutes(framework, [
170
+ { path: "/", intentId: "home", controller: new HomeController() },
171
+ { path: "/welcome", intentId: "home" }, // reuses the registered HomeController
172
+ { path: "/landing/:slug", intentId: "home" }, // same intent, params differ
173
+ ]);
174
+ ```
175
+
176
+ This avoids duplicating controller instances when only the URL surface differs. Authenticated `/admin` reusing the `home` intent in the earlier example is the same pattern.
177
+
178
+ ## Inspecting the resolved match
179
+
180
+ For diagnostics or custom routing, call `Router.resolve()` directly:
181
+
182
+ ```ts
183
+ const match = framework.router.resolve("/products/42");
184
+ // {
185
+ // intent: { intentId: "product", params: { id: "42" } },
186
+ // action: { kind: "flow", url: "/products/42" },
187
+ // renderMode: "ssr",
188
+ // guards: { before: [...], after: [...] },
189
+ // }
190
+ ```
191
+
192
+ `router.resolve()` returns `null` for unmatched URLs — handle this in your server-side 404 logic.
193
+
194
+ ## Try it
195
+
196
+ A live `Router` instance is registered with the sample routes below. Type a URL on the left and watch `Router.resolve()` produce a `RouteMatch` on the right — the same code path the framework uses at runtime.
197
+
198
+ <Ch02RouteResolver />
199
+
200
+ ## Next
201
+
202
+ - [Middleware](./03-middleware.md) — gating navigation, redirects, denies
203
+ - [Rendering & hydration](./04-rendering-and-hydration.md) — what happens after the controller produces a page
@@ -0,0 +1,220 @@
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
+ ## 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.
214
+
215
+ <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