@finesoft/front 0.5.1 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/README.md +4 -4
  2. package/dist/Outlet.svelte +39 -0
  3. package/dist/Outlet.svelte.d.ts +7 -0
  4. package/dist/browser-DIU6Sxl3.mjs +1237 -0
  5. package/dist/browser-kFMjlLGT.d.mts +262 -0
  6. package/dist/browser.d.mts +7 -2
  7. package/dist/browser.mjs +9 -1
  8. package/dist/controller-types-CgmJ6-le.d.mts +16 -0
  9. package/dist/cookies-Bpf9VayB.d.mts +779 -0
  10. package/dist/fetch-policy-BHT8RtrL.mjs +82 -0
  11. package/dist/host-guard-DDWxLpFL.mjs +222 -0
  12. package/dist/http-B6CJqDyf.d.mts +46 -0
  13. package/dist/http-CaxrMD1A.d.mts +1 -0
  14. package/dist/http-D70PL72H.mjs +257 -0
  15. package/dist/http.d.mts +3 -0
  16. package/dist/http.mjs +2 -0
  17. package/dist/index-node.d.mts +15 -0
  18. package/dist/index-node.mjs +15 -0
  19. package/dist/index.d.mts +48 -697
  20. package/dist/index.mjs +44 -261
  21. package/dist/load-node.d.mts +5 -0
  22. package/dist/load-node.mjs +10 -0
  23. package/dist/load-portable.d.mts +5 -0
  24. package/dist/load-portable.mjs +8 -0
  25. package/dist/lru-map-BKoUAySU.mjs +50 -0
  26. package/dist/messages-CAt2QdGr.mjs +140 -0
  27. package/dist/native-contract-DuR25hYB.d.mts +14 -0
  28. package/dist/native-contract.d.mts +2 -0
  29. package/dist/native-contract.mjs +1 -0
  30. package/dist/node-D9hB4dsz.d.mts +35 -0
  31. package/dist/node.d.mts +2 -0
  32. package/dist/node.mjs +59 -0
  33. package/dist/path-CGFl2w7D.mjs +113 -0
  34. package/dist/path-CXT6xGPO.d.mts +261 -0
  35. package/dist/portable-CaxrMD1A.d.mts +1 -0
  36. package/dist/portable.d.mts +11 -0
  37. package/dist/portable.mjs +12 -0
  38. package/dist/proxy-1SphZ7x7.mjs +436 -0
  39. package/dist/proxy-2dSWO-Xw.d.mts +53 -0
  40. package/dist/public-types-BcJM-AYc.mjs +835 -0
  41. package/dist/react-DhwBRw01.d.mts +16 -0
  42. package/dist/react.d.mts +3 -0
  43. package/dist/react.mjs +29 -0
  44. package/dist/rolldown-runtime-B4iAMlE-.mjs +35 -0
  45. package/dist/secure-fetch-Xlht2jd7.d.mts +30 -0
  46. package/dist/server-controller-proxy-BkVuVWVD.d.mts +38 -0
  47. package/dist/session-DnB4ZC3x.d.mts +1279 -0
  48. package/dist/src-Ftl_0rhu.mjs +28 -0
  49. package/dist/src-qwx7Vw8g.mjs +3807 -0
  50. package/dist/ssr-BEUNDvbj.d.mts +210 -0
  51. package/dist/ssr-C8xnYXoY.mjs +357 -0
  52. package/dist/ssr.d.mts +3 -0
  53. package/dist/ssr.mjs +3 -0
  54. package/dist/svelte-Dr5to3SE.d.mts +16 -0
  55. package/dist/svelte.d.mts +3 -0
  56. package/dist/svelte.mjs +13 -0
  57. package/dist/typegen-C-WeJCtf.d.mts +12 -0
  58. package/dist/typegen-cli.d.mts +1 -0
  59. package/dist/typegen-cli.mjs +11 -0
  60. package/dist/typegen.d.mts +3 -0
  61. package/dist/typegen.mjs +2 -0
  62. package/dist/types-BuaZHRG7.mjs +402 -0
  63. package/dist/undici-CPfL25Hr.mjs +22262 -0
  64. package/dist/vite-Cj4SPA8D.d.mts +277 -0
  65. package/dist/vite.d.mts +4 -0
  66. package/dist/vite.mjs +2354 -0
  67. package/dist/vue-DGmzuKho.d.mts +32 -0
  68. package/dist/vue.d.mts +3 -0
  69. package/dist/vue.mjs +56 -0
  70. package/dist/web.d.mts +6 -0
  71. package/dist/web.mjs +8 -0
  72. package/dist/worker.d.mts +2 -0
  73. package/dist/worker.mjs +2 -0
  74. package/docs/01-getting-started.md +67 -199
  75. package/docs/02-routing-and-controllers.md +163 -241
  76. package/docs/03-middleware.md +10 -212
  77. package/docs/04-rendering-and-hydration.md +6 -333
  78. package/docs/05-i18n.md +7 -237
  79. package/docs/06-http-client.md +20 -263
  80. package/docs/07-di-container.md +23 -257
  81. package/docs/08-observability.md +6 -286
  82. package/docs/09-server-and-deployment.md +59 -219
  83. package/docs/10-features-platform-pwa.md +7 -231
  84. package/docs/11-navigation.md +143 -288
  85. package/docs/12-session-restoration.md +6 -214
  86. package/docs/README.md +7 -7
  87. package/docs/advanced/custom-action-handler.md +29 -229
  88. package/docs/advanced/custom-adapter.md +7 -259
  89. package/docs/advanced/custom-event-recorder.md +7 -312
  90. package/docs/advanced/inline-proxy-codegen.md +6 -185
  91. package/docs/advanced/multi-tenant-scopes.md +10 -323
  92. package/docs/engineering/ci-release-flow.md +35 -222
  93. package/docs/engineering/project-structure.md +34 -277
  94. package/docs/engineering/testing.md +8 -310
  95. package/docs/pitfalls/container-scope-leak.md +2 -214
  96. package/docs/pitfalls/i18n-bundle-size.md +6 -176
  97. package/docs/pitfalls/proxy-binary-payloads.md +8 -12
  98. package/docs/pitfalls/ssr-hydration-mismatch.md +4 -160
  99. package/docs/pitfalls/ssr-vs-csr-globals.md +9 -170
  100. package/docs/zh/01-getting-started.md +67 -199
  101. package/docs/zh/02-routing-and-controllers.md +166 -244
  102. package/docs/zh/03-middleware.md +10 -212
  103. package/docs/zh/04-rendering-and-hydration.md +6 -333
  104. package/docs/zh/05-i18n.md +7 -237
  105. package/docs/zh/06-http-client.md +20 -263
  106. package/docs/zh/07-di-container.md +23 -257
  107. package/docs/zh/08-observability.md +6 -283
  108. package/docs/zh/09-server-and-deployment.md +59 -219
  109. package/docs/zh/10-features-platform-pwa.md +7 -231
  110. package/docs/zh/11-navigation.md +130 -290
  111. package/docs/zh/12-session-restoration.md +6 -214
  112. package/docs/zh/README.md +4 -4
  113. package/docs/zh/advanced/custom-action-handler.md +29 -229
  114. package/docs/zh/advanced/custom-adapter.md +7 -259
  115. package/docs/zh/advanced/custom-event-recorder.md +7 -312
  116. package/docs/zh/advanced/inline-proxy-codegen.md +6 -185
  117. package/docs/zh/advanced/multi-tenant-scopes.md +10 -323
  118. package/docs/zh/engineering/ci-release-flow.md +35 -222
  119. package/docs/zh/engineering/project-structure.md +34 -277
  120. package/docs/zh/engineering/testing.md +8 -310
  121. package/docs/zh/pitfalls/container-scope-leak.md +2 -214
  122. package/docs/zh/pitfalls/i18n-bundle-size.md +6 -176
  123. package/docs/zh/pitfalls/proxy-binary-payloads.md +8 -12
  124. package/docs/zh/pitfalls/ssr-hydration-mismatch.md +4 -160
  125. package/docs/zh/pitfalls/ssr-vs-csr-globals.md +9 -170
  126. package/package.json +118 -20
  127. package/dist/browser-BHhVWXik.mjs +0 -2
  128. package/dist/browser-BV2BBXm7.d.mts +0 -2811
@@ -1,314 +1,236 @@
1
- # 2. Routing & controllers
1
+ # Routes, controllers and typed pages
2
2
 
3
- The framework's routing layer maps URLs to **intents**, intents to **controllers**, and controllers produce **pages**. This chapter covers all three.
3
+ All six templates load pages with `BaseController`, which remains a public API. `definePage` connects a controller factory or a function handler to a Web application. Both forms share the runtime, execution scopes and policies.
4
4
 
5
- ## The mental model
5
+ ## Responsibilities
6
6
 
7
- ```
8
- URL ──Router.resolve()──▶ RouteMatch { intent, renderMode, guards }
9
-
10
-
11
- IntentDispatcher.dispatch(intent)
12
-
13
-
14
- Controller.execute() → Page
15
- ```
7
+ | API | Responsibility |
8
+ | --------------------------------- | ----------------------------------------------------------------------------------------------- |
9
+ | `BaseController<TInput, TResult>` | Implement `execute`; inherited `perform` handles parameters, cancellation and `fallback` |
10
+ | `definePage({ id, create })` | Declare the page-loading factory and provide reusable `route`, `leaf` and `bindView` references |
11
+ | `defineWebApp` | Assemble page declarations, routes, guards and optional navigation structure |
12
+ | Native page component | Receive `page` data, render UI and handle interactions |
16
13
 
17
- A route definition combines:
14
+ Import controllers, pages, routes and public-data declarations from `@finesoft/front`.
18
15
 
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
- - Optional **param codecs** (`params` / `query`) that validate and type the URL params
23
- - An optional **render mode** (`ssr` / `csr` / `prerender`)
24
- - Optional **guards** (`beforeLoad` / `afterLoad`)
16
+ ## Load a page with BaseController
25
17
 
26
- ## Defining routes
18
+ `src/lib/controllers/product.ts`:
27
19
 
28
20
  ```ts
29
- // src/bootstrap.ts
30
- import { type Framework, defineRoutes } from "@finesoft/front";
31
- import { HomeController } from "./lib/controllers/home";
32
- import { ProductController } from "./lib/controllers/product";
33
- import { authGuard } from "./lib/guards/auth";
34
-
35
- export function bootstrap(framework: Framework): void {
36
- defineRoutes(framework, [
37
- // Plain SSR route
38
- { path: "/", intentId: "home", controller: new HomeController() },
39
-
40
- // Dynamic segment
41
- { path: "/products/:id", intentId: "product", controller: new ProductController() },
42
-
43
- // CSR-only (server returns an empty shell)
44
- {
45
- path: "/dashboard",
46
- intentId: "dashboard",
47
- controller: new DashboardController(),
48
- renderMode: "csr",
49
- },
50
-
51
- // Statically prerendered at build time
52
- {
53
- path: "/about",
54
- intentId: "about",
55
- controller: new AboutController(),
56
- renderMode: "prerender",
57
- },
58
-
59
- // Protected route — reuses the home intent but gates with a guard
60
- {
61
- path: "/admin",
62
- intentId: "home",
63
- controller: new HomeController(),
64
- beforeLoad: [authGuard],
65
- },
66
- ]);
21
+ import { BaseController, DEP_KEYS } from "@finesoft/front";
22
+ import { markPublic, type BasePage } from "@finesoft/front";
23
+
24
+ export interface ProductPage extends BasePage {
25
+ pageType: "product";
26
+ product: { id: number; name: string };
67
27
  }
68
- ```
69
28
 
70
- ### Route options
29
+ export class ProductController extends BaseController {
30
+ async execute({ params, context }): Promise<ProductPage> {
31
+ const logger = await context.get(DEP_KEYS.LOGGER_FACTORY);
32
+ logger.loggerFor("ProductController").info(`Loading product ${params.id}`);
33
+ context.record("product.load", { productId: params.id });
34
+
35
+ return markPublic(
36
+ {
37
+ id: String(params.id),
38
+ pageType: "product",
39
+ title: `Product ${params.id}`,
40
+ product: { id: params.id, name: `Product ${params.id}` },
41
+ },
42
+ { product: { id: true, name: true } },
43
+ );
44
+ }
71
45
 
72
- | Field | Type | Notes |
73
- | ------------ | -------------------------------- | ------------------------------------------------------------------------ |
74
- | `path` | `string` | Path pattern with `:param` placeholders. Trailing `/` is normalized. |
75
- | `intentId` | `string` | Logical operation name. Used to register the controller. |
76
- | `controller` | `BaseController<TParams, TPage>` | Optional if the intent is already registered. |
77
- | `params` | codec map | Validate/convert **path** params. Keys must appear in `path`. See below. |
78
- | `query` | codec map | Validate/convert **query** params. Keys are open. See below. |
79
- | `renderMode` | `"ssr" \| "csr" \| "prerender"` | Default `"ssr"`. See [chapter 4](./04-rendering-and-hydration.md). |
80
- | `beforeLoad` | `BeforeLoadGuard[]` | Run before the controller. See [chapter 3](./03-middleware.md). |
81
- | `afterLoad` | `AfterLoadGuard[]` | Run after the page is produced. |
46
+ override fallback({ params }): ProductPage {
47
+ return markPublic(
48
+ {
49
+ id: String(params.id),
50
+ pageType: "product",
51
+ title: "Product unavailable",
52
+ product: { id: params.id, name: "Unavailable" },
53
+ },
54
+ { product: { id: true, name: true } },
55
+ );
56
+ }
57
+ }
58
+ ```
59
+
60
+ The framework maintains type imports, method annotations and base-class generics from the route declaration below; these are omitted in the initial source shown here. Run `vp dev`, `vp check` or the build to generate them. Types live in `.finesoft/controller-types.d.ts`, with no declaration block appended to the controller. See [automatic controller types](./11-navigation.md) for setup.
82
61
 
83
- ### Path patterns
62
+ Both methods receive one object: `execute({ params, query, context })` and `fallback({ params, query, context, error })`. Destructure only the fields you need. Query remains separate from path parameters and is inferred from the route's `query` schemas.
84
63
 
85
- - Static: `/about`
86
- - Parameterized: `/products/:id`, `/users/:userId/posts/:postId`
87
- - Optional param: `/blog/:slug?` matches both `/blog` and `/blog/hello`.
64
+ This example uses local data; application controllers can call services from `execute`. `context` provides its `signal`, `fetch`, `get(token)` and `execute(operation, input)`. Pass `context.signal` to asynchronous request APIs so cancellation reaches the actual work.
88
65
 
89
- By default, path and query params arrive in `controller.execute(params, container)` as a **string-keyed object**. Attach codecs (next section) to validate and convert them.
66
+ `fallback` runs when `execute` throws an ordinary error and still returns `ProductPage`; this example chooses to display an unavailable state. The default implementation rethrows so page loading can handle the error. Cancellation, `AbortError` and `ExecutionError("cancelled")` propagate without entering `fallback`. Runtime policies execute outside the controller, so policy rejection also bypasses controller recovery.
90
67
 
91
- ## Typed route params
68
+ ## Register a factory, route and navigation target
92
69
 
93
- Codecs turn raw string params into validated, converted, **compile-time-typed** values. Built-ins cover the common cases with zero dependencies; any [Standard Schema](https://standardschema.dev) (zod, valibot, arktype, …) works too.
70
+ `src/app-definition.ts`:
94
71
 
95
72
  ```ts
96
- import { defineRoutes, int, list, oneOf, optional, str, withDefault } from "@finesoft/front";
97
-
98
- defineRoutes(framework, [
99
- {
100
- path: "/products/:id",
101
- intentId: "product",
102
- controller: new ProductController(),
103
- params: { id: int({ min: 1 }) }, // :id validated as a positive integer, converted to number
104
- query: {
105
- page: withDefault(int({ min: 1 }), 1), // ?page= → number, defaults to 1 when absent
106
- sort: optional(oneOf(["asc", "desc"] as const)), // optional "asc" | "desc"
107
- tags: list(str()), // ?tags=a&tags=b → string[]
108
- },
109
- },
110
- ]);
73
+ import { int } from "@finesoft/front";
74
+ import { definePage, defineWebApp } from "@finesoft/front";
75
+ import { ProductController } from "./lib/controllers/product";
76
+
77
+ export const product = definePage({
78
+ id: "load-product",
79
+ create: () => new ProductController(),
80
+ routes: [{ path: "/products/:id", params: { id: int() } }],
81
+ });
82
+ export const target = product.leaf({ id: 42 });
83
+
84
+ export const app = defineWebApp({
85
+ id: "example",
86
+ pages: [product],
87
+ getErrorPage: (status, message) => ({ id: String(status), pageType: "error", title: message }),
88
+ });
111
89
  ```
112
90
 
113
- ### Built-in codecs
91
+ The page declaration owns the operation id; controllers do not repeat it. The URL `/products/42` is decoded by `int()`, so `execute` receives `params: { id: 42 }`. In application code, `product.leaf({ id: 42 })` retains the same parameter type.
114
92
 
115
- | Codec | Output | Validates |
116
- | ----------------------- | ------------- | ----------------------------------------------------------- |
117
- | `str(opts?)` | `string` | `minLength` / `maxLength` / `pattern` (`RegExp`) |
118
- | `int(opts?)` | `number` | integer + `min` / `max` |
119
- | `num(opts?)` | `number` | finite number + `min` / `max` |
120
- | `bool()` | `boolean` | `"true" \| "1" \| "false" \| "0"` |
121
- | `oneOf([...] as const)` | literal union | membership |
122
- | `uuid()` | `string` | UUID v1–v5 |
123
- | `list(item, opts?)` | `T[]` | multi-value query; each item via `item` + `min`/`max` count |
93
+ `create` returns a new controller for each actual execution. Declaration, route discovery and reference helpers only read definitions; consuming a prefetched or retained page result also skips controller creation. Keep request identity in execution context or scoped providers, and page drafts in page instances.
124
94
 
125
- Modifiers wrap a codec (codecs stay plain serializable data no chained `.optional()`):
95
+ `id` is operation identity, `pageType` selects a view, and each leaf receives a fresh EntryId. `product.bindView("product", ProductView)` checks literal result page types without inferring one from the operation id. Retain `ProductPage.pageType: "product"` to get this check; a widened `BasePage.pageType: string` cannot constrain view names.
126
96
 
127
- - `optional(codec)` missing input `undefined`; renders the key as **optional** (`page?: T`).
128
- - `withDefault(codec, fallback)` — missing input → `fallback`; key stays required.
97
+ Typed leaf parameters retain controller types. External URLs still require explicit codec decoding. Generics provide compile-time constraints, not runtime input validation, and cannot prove that an arbitrary codec matches business validation rules.
129
98
 
130
- ### Validation = fall-through to 404
99
+ ## Function handlers for simple pages
131
100
 
132
- A failed codec means the route **doesn't match** the router continues to the next route, falling through to your existing 404 if nothing else catches it. There is no separate `400` channel. This lets overlapping routes disambiguate by type:
101
+ Use `handler` when a page only needs a direct data-loading function. It can also access dependencies, cancellation and nested operations through execution context. Handle any required recovery inside the function. Choose either `create` or `handler`.
133
102
 
134
103
  ```ts
135
- defineRoutes(framework, [
136
- { path: "/item/:id", intentId: "item-by-id", controller, params: { id: int() } },
137
- { path: "/item/:slug", intentId: "item-by-slug", controller, params: { slug: str() } },
138
- ]);
139
- // /item/42 item-by-id (int matches)
140
- // /item/hello → item-by-slug (int rejects → falls through to str)
104
+ import { definePage, markPublic } from "@finesoft/front";
105
+
106
+ export const home = definePage({
107
+ id: "load-home",
108
+ handler: () => markPublic({ id: "home", pageType: "home" as const, title: "Home" }, []),
109
+ });
141
110
  ```
142
111
 
143
- ### Compile-time param types
112
+ Templates use controller classes for consistent organization; function handlers suit short loading logic. Existing function handlers receive `(params, context, query)`. Inheritance is optional: a `create` factory can also return an object implementing `perform(params, context, query)`.
113
+
114
+ ## Controllers for independent business operations
144
115
 
145
- `InferParams` / `InferQuery` derive the controller's param type straight from the codec objects no hand-written generics to keep in sync:
116
+ The result generic of `BaseController` does not require a page. Bind a class to an operation with `implementController` to execute it in a standalone data runtime. HTTP and Worker hosts can also invoke that operation.
146
117
 
147
118
  ```ts
148
119
  import {
149
120
  BaseController,
150
- type InferParams,
151
- type InferQuery,
152
- int,
153
- oneOf,
154
- optional,
121
+ createRuntime,
122
+ defineApp,
123
+ defineOperation,
124
+ implementController,
125
+ type ControllerInput,
155
126
  } from "@finesoft/front";
156
127
 
157
- const params = { id: int() };
158
- const query = { sort: optional(oneOf(["asc", "desc"] as const)) };
128
+ type TotalInput = { unitPrice: number; quantity: number };
129
+ interface TotalResult {
130
+ total: number;
131
+ }
159
132
 
160
- class ProductController extends BaseController<
161
- InferParams<typeof params> & InferQuery<typeof query>, // { id: number; sort?: "asc" | "desc" }
162
- ProductPage
163
- > {
164
- readonly intentId = "product";
165
- execute(params) {
166
- // params.id: number, params.sort: "asc" | "desc" | undefined
133
+ class TotalController extends BaseController<ControllerInput<TotalInput>, TotalResult> {
134
+ execute({ params }: ControllerInput<TotalInput>): TotalResult {
135
+ return { total: params.unitPrice * params.quantity };
167
136
  }
168
137
  }
169
- ```
170
-
171
- ### `route()` — param-key safety
172
138
 
173
- The plain array-object form already checks that every `params` key appears in the `path`. The `route(path, def)` helper gives the same check as a standalone, composable entry:
139
+ const calculateTotal = defineOperation<TotalInput, TotalResult>({
140
+ id: "calculate-total",
141
+ kind: "query",
142
+ });
143
+ const runtime = createRuntime({
144
+ app: defineApp({
145
+ id: "data-example",
146
+ operations: [calculateTotal],
147
+ implementations: [implementController(calculateTotal, () => new TotalController())],
148
+ }),
149
+ });
174
150
 
175
- ```ts
176
- route("/products/:id", { intentId: "product", params: { id: int() } }); // ✓
177
- route("/products/:id", { intentId: "product", params: { slug: str() } }); // compile error: "slug" is not in the path
151
+ try {
152
+ const result = await runtime.execute(calculateTotal, { unitPrice: 29, quantity: 2 });
153
+ console.log(result); // { total: 58 }
154
+ } finally {
155
+ await runtime.dispose();
156
+ }
178
157
  ```
179
158
 
180
- ### `defineRoute()` auto-typed handlers
181
-
182
- `defineRoute(path, def)` takes a **handler** function instead of a controller class, and infers its params type from the codecs automatically — no `InferParams` needed. It mirrors `BaseController`'s `try/catch → fallback`:
159
+ The operation declaration defines the input/output contract; the controller supplies the implementation. Exposing the operation over HTTP still requires an explicit endpoint. Registering a controller does not publish an API.
183
160
 
184
- ```ts
185
- defineRoute("/products/:id", {
186
- intentId: "product",
187
- params: { id: int() },
188
- query: { page: withDefault(int(), 1) },
189
- handler: (params, container) => {
190
- // params: { id: number; page: number } — inferred from the codecs
191
- return loadProduct(params.id, params.page);
192
- },
193
- fallback: (params, error) => errorPage(error), // optional
194
- });
195
- ```
161
+ <Ch02RouteResolver />
196
162
 
197
- Routes without `params` / `query` behave exactly as before — params stay strings, runtime is unchanged.
163
+ ## Server controllers and request context
198
164
 
199
- ## Writing a controller
165
+ Use `BaseServerController` from `@finesoft/front` for pages requiring secrets, HttpOnly cookies, or response mutation. Keep `execute({ params, query, context })`, `fallback({ params, query, context, error })`, and the same `definePage({ create, routes })` registration. The type generator maintains the existing compact `Input` / `Failure` references and selects the context from the base class.
200
166
 
201
167
  ```ts
202
- // src/lib/controllers/product.ts
203
- import { BaseController, type Container, type HttpClient } from "@finesoft/front";
204
-
205
- interface ProductPage {
206
- kind: "product";
207
- id: string;
208
- name: string;
209
- price: number;
210
- }
211
-
212
- export class ProductController extends BaseController<{ id: string }, ProductPage> {
213
- readonly intentId = "product";
214
-
215
- async execute(params: { id: string }, container: Container): Promise<ProductPage> {
216
- const http = container.resolve<HttpClient>("http");
217
- const product = await http.get<{ name: string; price: number }>(
218
- `/api/products/${params.id}`,
168
+ import { BaseServerController } from "@finesoft/front";
169
+ import { markPublic } from "@finesoft/front";
170
+ import { loadAccount } from "./account-service";
171
+
172
+ export class AccountController extends BaseServerController {
173
+ async execute({ params, context }) {
174
+ const account = await loadAccount(params.id, context.getCookie("session"));
175
+ context.responseHeaders.set("Cache-Control", "private, no-store");
176
+ return markPublic(
177
+ { id: String(params.id), pageType: "account", title: account.name, account },
178
+ { account: { id: true, name: true } },
219
179
  );
220
- return {
221
- kind: "product",
222
- id: params.id,
223
- name: product.name,
224
- price: product.price,
225
- };
226
- }
227
-
228
- fallback(params: { id: string }, _error: unknown): ProductPage {
229
- return { kind: "product", id: params.id, name: "Not available", price: 0 };
230
180
  }
231
181
  }
232
182
  ```
233
183
 
234
- ### Controller contract
235
-
236
- | Member | Required | Purpose |
237
- | ---------- | -------- | ---------------------------------------------------------------------------------- |
238
- | `intentId` | yes | Must match the route's `intentId` (or the `IntentDispatcher.register` call). |
239
- | `execute` | yes | Produce the page. Receives parsed path params and the request-scoped DI container. |
240
- | `fallback` | yes | Return a degraded page when `execute()` throws. Must be synchronous and total. |
241
-
242
- `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.
184
+ Generated annotations are omitted above; `account-service` is application code. The controller may live in any directory. Vite replaces its entire module with browser references, while SSR executes the original implementation directly. Browser navigation uses same-origin `POST /__finesoft/controller`; the server revalidates routes and executes policies and guards. Hydration consumes existing SSR data without another call.
243
185
 
244
- ### Why `fallback` is mandatory
186
+ | Capability | Shared page ControllerContext | ServerControllerContext |
187
+ | ----------------------------------------------- | ------------------------------------------------ | --------------------------------------------------- |
188
+ | `params`, `query` | Separate, route-inferred inputs | Same |
189
+ | `url`, `path`, `intent`, `isServer` | Current page and execution environment | Same; `isServer: true` |
190
+ | `getCookie(name)` | Browser-readable cookies, or SSR request cookies | Original request cookies, including HttpOnly |
191
+ | `getHeader(name)` | SSR request headers; unavailable in the browser | Current request headers |
192
+ | DI, fetch, signal, operations, logging, tracing | Existing execution capabilities | Same |
193
+ | `request`, `responseHeaders` | No dedicated interface | Original Request and outgoing Headers |
194
+ | `setCookie`, `deleteCookie` | No dedicated interface | Write outgoing cookies; leave the request unchanged |
245
195
 
246
- 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.
196
+ Generated page inputs use `ControllerContext` or `ServerControllerContext`. Portable data operations continue to use `ExecutionContext`. Direct page-operation execution without navigation has empty `url` / `path`. For remote loads, `request.url` names the transport endpoint; use `context.url` for the page address.
247
197
 
248
- ## Render modes
198
+ ### Module isolation
249
199
 
250
- | Mode | Server returns | When to use |
251
- | ------------- | -------------------------------------------- | -------------------------------------------------------- |
252
- | `"ssr"` | Fully rendered HTML + serialized data | Default. Best for SEO and TTFB-sensitive pages. |
253
- | `"csr"` | Empty shell HTML; controller runs in browser | Authenticated dashboards, heavy-personalization pages. |
254
- | `"prerender"` | Static HTML built at deploy time | Marketing, docs, blog. Combine with ISR (see chapter 4). |
200
+ A server controller module may export controller classes and types only. Its local runtime imports are also private to the server; importing these dependencies from client code fails. Use `import type` for shared contracts and separate modules for client implementations. Keep secret-dependent construction inside the protected module or server configuration, rather than arguments to the shared `create` factory.
255
201
 
256
- The mode is **per-route**, so you can mix freely. The framework rebuilds prerendered routes at build time; SSR routes execute on every request.
202
+ Client bundles and source maps omit the replaced implementation. The development plugin rejects raw controller assets and direct browser source requests for protected dependencies. It indexes modules once, caches ASTs, and reanalyzes changed files. Dependencies remain protected for the lifetime of a development server; restart when intentionally moving one back into shared code. Computed file paths, `public/` assets, and independently copied source are outside the static import boundary. Development servers still belong on trusted development networks.
257
203
 
258
- ## Registering controllers without routes
204
+ Use the framework Vite plugin and a request host such as Node or Worker. Static-only hosting cannot run the remote endpoint. The endpoint loads registered server page controllers; it does not expose arbitrary methods or replace explicit login/registration command endpoints.
259
205
 
260
- You can register a controller for an intent without exposing it as a route. This is useful for intents triggered only by `dispatchAction`:
206
+ ### Explicit request and response tools
261
207
 
262
- ```ts
263
- framework.intentDispatcher.register("checkout", new CheckoutController());
264
-
265
- // Elsewhere:
266
- const page = await framework.intentDispatcher.dispatch({
267
- intentId: "checkout",
268
- params: { cartId },
269
- });
270
- ```
208
+ The framework supplies `request`, `getHeader`, `getCookie`, `fetch`, `responseHeaders`, `setCookie` and `deleteCookie`. Applications own login state, authentication, token storage/refresh and the choice to forward credentials.
271
209
 
272
- Routes are simply intent dispatches keyed by URL.
210
+ Server `context.fetch` uses only caller-supplied headers. It does not inherit Cookie/Authorization from the outer request or copy internal Set-Cookie values to the page response. The credentials option does not create a server-side cookie store. Requests made by the browser retain the browser's native same-origin cookie behavior.
273
211
 
274
- ## One intent, many routes
275
-
276
- Same intent can serve different URLs:
212
+ For example, application code can choose to forward Authorization to a particular internal endpoint and copy that endpoint's cookies to the outgoing response:
277
213
 
278
214
  ```ts
279
- defineRoutes(framework, [
280
- { path: "/", intentId: "home", controller: new HomeController() },
281
- { path: "/welcome", intentId: "home" }, // reuses the registered HomeController
282
- { path: "/landing/:slug", intentId: "home" }, // same intent, params differ
283
- ]);
284
- ```
215
+ const headers = new Headers();
216
+ const authorization = context.getHeader("authorization");
217
+ if (authorization) headers.set("authorization", authorization);
285
218
 
286
- 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.
219
+ const response = await context.fetch("/api/account", { headers });
220
+ for (const cookie of response.headers.getSetCookie()) {
221
+ context.responseHeaders.append("set-cookie", cookie);
222
+ }
223
+ ```
287
224
 
288
- ## Inspecting the resolved match
225
+ The destination and header/cookie selection are application decisions. Reuse a business HttpClient interceptor or request-scoped provider through context.get when several controllers share the policy.
289
226
 
290
- For diagnostics or custom routing, call `Router.resolve()` directly. It is **async** (codecs may validate asynchronously), so `await` it:
227
+ Applications can also construct outgoing cookies directly:
291
228
 
292
229
  ```ts
293
- const match = await framework.router.resolve("/products/42");
294
- // {
295
- // intent: { id: "product", params: { id: "42" } },
296
- // action: { kind: "flow", url: "/products/42" },
297
- // renderMode: "ssr",
298
- // beforeGuards: [...],
299
- // afterGuards: [...],
300
- // }
230
+ context.setCookie("theme", "dark", { path: "/", sameSite: "Lax" });
231
+ context.deleteCookie("theme", { path: "/" });
301
232
  ```
302
233
 
303
- `router.resolve()` resolves to `null` for unmatched URLs handle this in your server-side 404 logic.
304
-
305
- ## Try it
306
-
307
- 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.
308
-
309
- <Ch02RouteResolver />
310
-
311
- ## Next
234
+ getCookie always reads the incoming request. Writing a response cookie does not change that request view or update identity. Later business logic uses its own result when it needs a new value. Explicit response headers are assembled into the final response, including error responses, with the framework retaining control of its error body content type and no-store policy.
312
235
 
313
- - [Middleware](./03-middleware.md) gating navigation, redirects, denies
314
- - [Rendering & hydration](./04-rendering-and-hydration.md) — what happens after the controller produces a page
236
+ Client-supplied context or bindings are never accepted as identity. markPublic continues to control data sent to the browser; secrets must also stay out of rendered HTML.