@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.
- package/docs/01-getting-started.md +230 -0
- package/docs/02-routing-and-controllers.md +203 -0
- package/docs/03-middleware.md +220 -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 +203 -0
- package/docs/zh/03-middleware.md +220 -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 +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
|