@finesoft/front 0.5.1 → 0.5.3

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-CR5vhgXg.mjs +1317 -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-BiRlUanX.d.mts +786 -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-2dSWO-Xw.d.mts +53 -0
  39. package/dist/proxy-z02VvGIj.mjs +7520 -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-BQBfaaPO.mjs +3825 -0
  49. package/dist/src-Ftl_0rhu.mjs +28 -0
  50. package/dist/ssr-BLzYP4wU.d.mts +207 -0
  51. package/dist/ssr-Tn4YkuxM.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-B1BT0N3t.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 +7 -332
  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 +9 -155
  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 +7 -332
  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 +9 -155
  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,355 +1,210 @@
1
- # 11. Navigation
1
+ # Structured navigation
2
2
 
3
- Chapters 2–4 cover the **flat single-page** lifecycle: one URL → one intent → one page. This chapter adds **structured navigation** a recursive, UI-agnostic navigation tree analogous to SwiftUI's `NavigationStack`, `TabView`, and `NavigationSplitView`.
3
+ Tabs, Stack and Split are immutable navigation declarations. Page references derive operation targets; branch and column names remain intentional layout identities.
4
4
 
5
- The framework owns navigation **state**, URL/history wiring, and per-destination intent dispatch. It ships **no UI**. Your `Page` models stay exactly as content-agnostic as before — you render tabs, stacks, and split views however you like with Svelte, React, or Vue.
5
+ ## Infer parameters from routes
6
6
 
7
- A single-leaf tree is **byte-for-byte** the flat single-page behavior, so this is fully opt-in: apps that never call `defineNavigation` are unaffected.
8
-
9
- ## The mental model
10
-
11
- Navigation state is a tree of four node kinds:
12
-
13
- ```
14
- NavigationNode = LeafNode | StackNode | TabsNode | SplitNode
15
- ```
16
-
17
- | Node | Holds | Meaning | SwiftUI |
18
- | ----------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------ | --------------------- |
19
- | `LeafNode` | `intent` + `params` | One destination (one intent dispatch) | a destination view |
20
- | `StackNode` | ordered `entries[]` | A path: `entries[0]` is the root, the last is the visible top | `NavigationStack` |
21
- | `TabsNode` | `active` key + `branches` | Parallel branches; **only the active one is visible** | `TabView` |
22
- | `SplitNode` | `columns[]` + optional `visibility` | Side-by-side columns; visible set is **all columns by default**, narrowable to `detailOnly` / `doubleColumn` | `NavigationSplitView` |
23
-
24
- A leaf carries `intent` + `params`, **not** a `Page`. The tree is pure, serializable data describing _where_ to go; the controller produces _what's there_ (the `Page`) during resolution and hands it back in a snapshot. This is what keeps the tree URL- and history-friendly.
25
-
26
- Interior nodes nest recursively — a `TabView` of `NavigationStack`s, a split whose detail column is a stack, and so on.
27
-
28
- ## Declaring a tree
29
-
30
- Constructors live alongside everything else in `@finesoft/front`:
7
+ Declare path and query types in the route and read them separately as `params.id` and `query.tab`. Existing handlers keep path parameters and context as the first two arguments; the third receives query:
31
8
 
32
9
  ```ts
33
- import { leaf, stack, tabs, split } from "@finesoft/front";
34
-
35
- // A single destination — equivalent to today's flat page
36
- leaf("home");
37
- leaf("product", { id: 42 });
38
-
39
- // A stack: root only, or root + already-pushed entries
40
- stack(leaf("feed"));
41
- stack([leaf("feed"), leaf("post", { id: 7 })]);
42
-
43
- // Tabs: each branch is its own stack
44
- tabs({
45
- active: "home",
46
- branches: {
47
- home: stack(leaf("home")),
48
- search: stack(leaf("search")),
49
- me: stack(leaf("me")),
10
+ import { definePage, defineWebApp, int, optional, oneOf } from "@finesoft/front";
11
+
12
+ export const product = definePage({
13
+ id: "product",
14
+ routes: [
15
+ {
16
+ path: "/products/:id",
17
+ params: { id: int() },
18
+ query: { tab: optional(oneOf(["details", "reviews"])) },
19
+ },
20
+ ],
21
+ handler(params, context, query) {
22
+ context.signal.throwIfAborted();
23
+ // params.id: number; query.tab?: "details" | "reviews"
24
+ return { id: params.id.toFixed(), pageType: "product" as const, title: "Product" };
50
25
  },
51
26
  });
27
+ export const definition = defineWebApp({
28
+ id: "shop",
29
+ pages: [product],
30
+ getErrorPage: (status, title) => ({ id: String(status), pageType: "error", title }),
31
+ });
52
32
 
53
- // Split: sidebar + detail, where detail is a stack
54
- split([
55
- { id: "sidebar", content: leaf("folders") },
56
- { id: "detail", content: stack(leaf("folder", { id: "inbox" })) },
57
- ]);
33
+ product.leaf({ id: 42 });
34
+ // product.leaf({ id: "42" }); // Type error
58
35
  ```
59
36
 
60
- `tabs()` derives a stable tab `order` from the `branches` insertion order unless you pass `order` explicitly. `stack()` accepts a single root node or an array of entries.
37
+ Use an independent `BaseController` subclass for recovery. `execute` can return synchronously or asynchronously; `fallback` is optional and bypassed on cancellation. Existing `perform` factories remain supported. `create` does not accept objects with only `execute/fallback`.
61
38
 
62
- ## A TabView of NavigationStacks
39
+ ### Independent controller classes
63
40
 
64
- The most common shape: a bottom tab bar where each tab keeps its own navigation depth.
41
+ Keep routes and classes in separate files. Declare input types only in the routes; use `execute({ params, query, context })` and optional `fallback({ params, query, context, error })` methods:
65
42
 
66
43
  ```ts
67
- // src/bootstrap.ts
68
- import { type Framework, defineRoutes, defineNavigation, leaf, stack, tabs } from "@finesoft/front";
69
- import { HomeController } from "./lib/controllers/home";
70
- import { SearchController } from "./lib/controllers/search";
71
- import { ProfileController } from "./lib/controllers/profile";
72
- import { PostController } from "./lib/controllers/post";
73
-
74
- export function bootstrap(framework: Framework): void {
75
- defineRoutes(framework, [
76
- { path: "/", intentId: "home", controller: new HomeController() },
77
- { path: "/search", intentId: "search", controller: new SearchController() },
78
- { path: "/me", intentId: "me", controller: new ProfileController() },
79
- { path: "/posts/:id", intentId: "post", controller: new PostController() },
80
- ]);
44
+ // controllers/product.ts — no repeated input annotations when authoring
45
+ import { BaseController } from "@finesoft/front";
46
+ import type { ProductPage } from "../models/product";
47
+
48
+ export class ProductController extends BaseController {
49
+ execute({ params, query, context }): ProductPage {
50
+ return { id: params.id.toFixed(), pageType: "product", title: "Product" };
51
+ }
81
52
  }
82
-
83
- // The navigation structure, declared once
84
- export const navigation = defineNavigation({
85
- initial: tabs({
86
- active: "home",
87
- branches: {
88
- home: stack(leaf("home")),
89
- search: stack(leaf("search")),
90
- me: stack(leaf("me")),
91
- },
92
- }),
93
- });
94
53
  ```
95
54
 
96
- `defineNavigation` returns a normalized definition with two adapters — `toBrowserConfig()` for CSR and `toSSRDefinition()` for SSR — so you declare the tree **once** and hand the right shape to each runner.
97
-
98
- ### Wiring it into the browser
99
-
100
- `startBrowserApp` gains an optional `navigation` field; when present, the `NavigationHandle` (and a unified `app` handle) is handed to your `mount` callback in its context, ready to use:
101
-
102
55
  ```ts
103
- // src/main.ts
104
- import { startBrowserApp } from "@finesoft/front";
105
- import { bootstrap, navigation } from "./bootstrap";
106
-
107
- startBrowserApp({
108
- bootstrap,
109
- callbacks,
110
- navigation: navigation.toBrowserConfig(),
111
- mount(target, { navigation: nav, app }) {
112
- // nav/app are ready at mount time (no callback needed).
113
- // Re-render whenever the snapshot changes:
114
- nav?.subscribe((snapshot) => mountNavigation(snapshot));
115
- if (nav) mountNavigation(nav.getSnapshot());
116
- // ... mount your UI into `target`, pass `app` to components ...
117
- return () => undefined;
118
- },
56
+ // app-definition.ts
57
+ import { definePage, int } from "@finesoft/front";
58
+ import { ProductController } from "./controllers/product";
59
+
60
+ export const product = definePage({
61
+ id: "product",
62
+ routes: [{ path: "/products/:id", params: { id: int() } }],
63
+ create: () => new ProductController(),
119
64
  });
120
65
  ```
121
66
 
122
- When `navigation` is present, the framework builds a `NavigationController` and a history bridge, resolves the first screen, and gives you the handle in the mount context. When it's absent, `startBrowserApp` runs the original flat single-page path unchanged.
123
-
124
- ## Driving navigation
67
+ The framework finds `BaseController` subclasses through the `create` return type. It maintains only type imports, parameter annotations and base-class generics. Declarations live in `.finesoft/controller-types.d.ts`; no declaration block is appended to controller files. These references appear in saved source, but application authors do not maintain them. Handwritten annotations are preserved; remove the input annotations and base input generic to let the generator manage a class. Keep explicit page return types on methods.
125
68
 
126
- The `NavigationHandle` exposes the operations. Each returns a `Promise<NavigationSnapshot>` (the committed tree plus every visible destination's resolved `Page`) and, in the browser, writes the new state to history/URL.
69
+ Generated source uses short type names and passes the complete input contract to the base class:
127
70
 
128
71
  ```ts
129
- // Push a destination onto the active stack
130
- await handle.push("post", { id: 7 });
131
-
132
- // Pop back
133
- await handle.pop(); // one level
134
- await handle.pop(2); // two levels never past the stack root
135
- await handle.popToRoot();
136
-
137
- // Replace the current top (e.g. login → dashboard without a back step)
138
- await handle.replaceTop("dashboard");
139
-
140
- // Switch the active tab — the other tabs keep their stack depth
141
- await handle.selectTab("search");
72
+ import type {
73
+ ProductControllerInput as Input,
74
+ ProductControllerFailure as Failure,
75
+ } from "../../../.finesoft/controller-types";
76
+
77
+ export class ProductController extends BaseController<Input, ProductPage> {
78
+ async execute({ params, query, context }: Input): Promise<ProductPage> {
79
+ /* application logic */
80
+ }
81
+ fallback({ params, error }: Failure): ProductPage {
82
+ /* recovery logic */
83
+ }
84
+ }
142
85
  ```
143
86
 
144
- `pop` never pops below a stack's root entry. With no explicit target, stack operations act on the **deepest active stack** (the one currently visible), and `selectTab` acts on the **outermost** tabs node exactly what you want for a tab bar driving the focused stack.
87
+ `Input` includes `params`, `query` and `context`; `Failure` adds `error`. The generator uses class-qualified aliases when multiple controllers share a file or names are occupied. `BaseController<Input, Result>` is the only generic form; handwritten inputs can use `ControllerInput<Params, Query>`. Direct `perform` calls retain parameter and result checks.
145
88
 
146
- ### Reading the result
89
+ Migrate existing positional `execute` / `fallback` methods to the object argument first. The generator updates type references; it does not rewrite method bodies and reports a migration error for the old multi-argument signature.
147
90
 
148
- A `NavigationSnapshot` is what you render:
91
+ Standard TypeScript declarations provide editor completion and command-line checking. Generation never executes application modules or instantiates controllers, and adds no runtime code. A class registered against several routes receives their input union, with normal narrowing required.
149
92
 
150
- ```ts
151
- const snapshot = handle.getSnapshot();
152
- snapshot.tree; // the current NavigationNode tree
153
- snapshot.destinations; // ResolvedDestination[]: { intent, params, page, status? }
154
- ```
155
-
156
- `destinations` is ordered to match `collectVisibleDestinations(tree)`: a tabs node contributes **only** its active branch, a split contributes **every** non-empty column. That ordering is also exactly what gets prefetched on the server.
157
-
158
- Your view layer walks `snapshot.tree` to lay out the chrome (which tabs exist, how deep each stack is) and reads `snapshot.destinations` for the page content. The framework never tells you _how_ to draw any of it.
93
+ When a route registration is removed but its class remains, generation retains that class's last input contract for independent use. Registering it again updates the contract from the new routes. Solution-style `tsconfig.json` files automatically select the referenced application project containing `src`; use `controllerTypes.tsconfig` when several projects qualify.
159
94
 
160
- ## A NavigationSplitView
161
-
162
- A split view shows multiple columns at once — the classic sidebar + detail (+ sub-detail) layout. Selecting in one column drives the next.
95
+ Templates already enable generation. For existing apps, install `typescript` as a development dependency and create the framework plugin outside `lazyPlugins` so `vp check` generates types when reading the config:
163
96
 
164
97
  ```ts
165
- export const navigation = defineNavigation({
166
- initial: split([
167
- { id: "sidebar", content: leaf("mailboxes") },
168
- { id: "list", content: undefined }, // chosen later
169
- { id: "detail", content: undefined },
170
- ]),
98
+ const front = finesoftFrontViteConfig({
99
+ controllerTypes: { root: import.meta.dirname },
100
+ });
101
+ export default defineConfig({
102
+ lint: { options: { typeAware: true, typeCheck: true } },
103
+ plugins: lazyPlugins(() => [front, react()]),
171
104
  });
172
105
  ```
173
106
 
174
- Use `selectColumn(columnId, intent, params?)` to set a column's content:
175
-
176
- ```ts
177
- // Pick a mailbox → fills the "list" column
178
- await handle.selectColumn("list", "messages", { mailbox: "inbox" });
179
-
180
- // Pick a message → fills the "detail" column
181
- await handle.selectColumn("detail", "message", { id: 1024 });
182
-
183
- // Re-pick a mailbox → clears "list" AND "detail" (everything after it)
184
- await handle.selectColumn("list", "messages", { mailbox: "archive" });
185
-
186
- // Clear a column explicitly by passing undefined for the intent
187
- await handle.selectColumn("detail", undefined);
188
- ```
189
-
190
- Setting a column **clears every column after it**. Re-choosing the sidebar correctly invalidates the open detail, so you never render a stale "old detail with a new sidebar" combination.
191
-
192
- By default every column is visible, so the snapshot's `destinations` contains one entry **per non-empty column** — the framework dispatches (and on the server, prefetches) each of them.
193
-
194
- ### Column visibility
107
+ `vp dev` watches source changes; `vp check` and builds also generate declarations. Run one of these commands when first opening a project. Ignore `.finesoft/` in Git and commit the managed source references. With standalone `tsc` or a custom toolchain, first call `generateControllerTypes({ root })` from `@finesoft/front`. Set `controllerTypes: false` to disable maintenance.
195
108
 
196
- Mirroring SwiftUI's `NavigationSplitViewVisibility`, a split carries an optional **visibility** bindable, serializable navigation state (not styling) that decides which columns count as visible, and therefore what gets prefetched on the server:
197
-
198
- | `visibility` | Visible columns |
199
- | ----------------------------- | ------------------------------- |
200
- | `automatic` (default) / `all` | every column |
201
- | `doubleColumn` | first + last (hides the middle) |
202
- | `detailOnly` | last (detail) only |
109
+ The app returned by `createBrowserApp({ definition, target })`, `createWebSession`, and the SSR `render(app)` callback retain the definition's parameter map:
203
110
 
204
111
  ```ts
205
- import { SPLIT_VISIBILITIES, visibleSplitColumns } from "@finesoft/front";
206
-
207
- // Declare it up front (e.g. deep-link straight to the detail)
208
- split(
209
- [
210
- { id: "sidebar", content: leaf("mailboxes") },
211
- { id: "detail", content: leaf("message", { id: 7 }) },
212
- ],
213
- SPLIT_VISIBILITIES.DETAIL_ONLY,
214
- );
215
-
216
- // Or change it at runtime — newly-visible columns are dispatched, hidden ones are dropped from the snapshot
217
- await handle.setVisibility(SPLIT_VISIBILITIES.DETAIL_ONLY); // only the detail destination remains
218
- await handle.setVisibility(SPLIT_VISIBILITIES.ALL); // re-prefetches sidebar + list
219
-
220
- // Render only the visible columns without re-implementing the mapping
221
- for (const col of visibleSplitColumns(splitNode)) renderColumn(col);
112
+ await app.perform({ kind: "push", intent: "product", params: { id: 42 } });
113
+ // A string id, missing id, or unknown intent produces a type error.
222
114
  ```
223
115
 
224
- `detailOnly` deep-links resolve and prefetch **only** the detail column on the server the hidden columns cost nothing until shown. Compact-width collapse (SwiftUI's `preferredCompactColumn`) is viewport-reactive rendering, so it stays entirely in your hands; read `getPlatform()` / the viewport and collapse the split into a stack view however you like.
116
+ `route()` and page reference `.route()` results retain their declared codec types. Keep the inferred factory return types instead of annotating `definition` with the broad `WebAppDefinition`. An explicit `PageRoute` annotation permits absent codecs, so inferred inputs must also account for that possibility; use `satisfies PageRoute` to check the declaration without erasing its concrete type. Components needing an explicit app type can use `WebAppView<typeof definition>` or `ViewProps<ProductPage, typeof definition>` without repeating parameter interfaces. The default `WebAppView` remains suitable for generic layouts and Outlets.
225
117
 
226
- ## Targeting a nested container
118
+ String arrays such as `routes: ["/items/:id", "/products/:id"]` still declare aliases. Unspecified path codecs produce strings; `:tab?` is optional. Different alias parameter shapes produce a union that the controller must narrow. When several aliases match the same parameters, provide `url` on the structured destination to select a path, for example `product.leaf({ id: 42 }, { url: "/products/42" })`.
227
119
 
228
- When a tree has more than one stack/tabs/split, pass an explicit `target` path to operate on a deeper one. A path is a list of steps from the root:
120
+ `optional`, `withDefault`, `list`, and third-party Standard Schema outputs determine receiver types. References and structured actions use this same output shape, so defaulted fields remain required there; URL navigation can omit them and let the Router apply defaults. The existing Router still validates and converts parameters, without a second schema pass.
229
121
 
230
- ```ts
231
- import type { NavigationPath } from "@finesoft/front";
232
-
233
- // The stack inside the detail column of a split
234
- const detailStack: NavigationPath = [
235
- { kind: "column", id: "detail" },
236
- { kind: "stack-entry", index: 0 },
237
- ];
238
-
239
- await handle.push("attachment", { id: 3 }, { target: detailStack });
240
- await handle.selectTab("photos", someTabsPath);
241
- ```
122
+ ### Query and path parameters
242
123
 
243
- Without a `target`, operations default to the active path, which is the right choice the vast majority of the time.
244
-
245
- ## Pure operations (no controller needed)
246
-
247
- Everything above is backed by pure, immutable tree functions you can use directly — for tests, optimistic computation, or building your own controller:
124
+ Both use the same codecs and inference, supplied as separate properties of the controller argument. No manual URL parsing is required:
248
125
 
249
126
  ```ts
250
- import {
251
- push,
252
- pop,
253
- selectTab,
254
- collectVisibleDestinations,
255
- resolveActivePath,
256
- } from "@finesoft/front";
257
-
258
- const next = push(tree, leaf("post", { id: 7 })); // returns a new tree
259
- const visible = collectVisibleDestinations(next); // readonly LeafNode[]
260
- const activePath = resolveActivePath(next);
127
+ routes: [
128
+ {
129
+ path: "/products/:id",
130
+ params: { id: int() },
131
+ query: {
132
+ q: withDefault(str(), ""),
133
+ tags: optional(list(str())),
134
+ },
135
+ },
136
+ ];
137
+ // execute({ params, query, context }):
138
+ // params.id: number; query.q: string; query.tags: string[] | undefined.
139
+ await app.perform({
140
+ kind: "push",
141
+ intent: "product",
142
+ params: { id: 42 },
143
+ query: { q: "a & b", tags: ["new", "sale"] },
144
+ });
261
145
  ```
262
146
 
263
- These never mutate their input — only the nodes on the changed path are rebuilt; the rest of the tree is shared by reference. Invalid targets (e.g. `selectTab` on a non-tabs node, popping an empty stack target) throw a `NavigationError`.
264
-
265
- ## Server-side rendering
147
+ The router encodes params in the path and query in the query string. Arrays preserve their order as repeated keys. URL navigation also accepts `/products/42?q=a%20%26%20b&tags=new&tags=sale` directly.
266
148
 
267
- SSR prefetches **all** visible destinations and serializes them plus the tree itself into the HTML, so the browser's first render reuses the server result without refetching. Multi-column split views naturally prefetch multiple intents.
149
+ Query names are not restricted to path placeholders. `optional` permits missing values; `withDefault` supplies missing values while explicit empty strings still undergo validation. `list` collects repeated keys and yields `[]` when absent. `optional(list(...))` yields `undefined` when absent; `withDefault(list(...), [...])` uses the default array. Single-value schemas keep the last repeated value. Undeclared query fields retain string compatibility without declared-field type guarantees; same-named path and query fields retain their independent values.
268
150
 
269
- Use `createSSRNavigationRender` with the SSR adapter:
151
+ ## Tree / 导航树
270
152
 
271
153
  ```ts
272
- // src/ssr.ts
273
- import { createSSRNavigationRender } from "@finesoft/front";
274
- import { bootstrap, navigation } from "./bootstrap";
275
- import { renderApp } from "./lib/render";
276
-
277
- export const render = createSSRNavigationRender({
278
- bootstrap,
279
- getErrorPage: (status, message) => ({
280
- id: `error-${status}`,
281
- pageType: "error",
282
- title: message,
283
- }),
284
- renderApp, // (page, framework, snapshot) => { html, head, css }
285
- navigation: navigation.toSSRDefinition(),
154
+ import { stack, tabs, split } from "@finesoft/front";
155
+ import { home, product } from "./pages";
156
+ export const navigation = tabs({
157
+ active: "catalog",
158
+ branches: {
159
+ catalog: stack([home.leaf(), product.leaf({ id: 42 })]),
160
+ compare: split([{ id: "left", content: product.leaf({ id: 42 }) }, { id: "right" }]),
161
+ },
286
162
  });
163
+ // defineWebApp({ ..., navigation })
287
164
  ```
288
165
 
289
- `renderApp` receives three arguments: the **primary** page (the active leaf's result compatible with the flat SSR `renderApp` signature), the framework, and the full multi-region `snapshot` so you can render tabs/split layouts:
290
-
291
- ```ts
292
- function renderApp(page, framework, snapshot) {
293
- // page → the focused destination (e.g. for <title>, status)
294
- // snapshot.tree → which tabs/columns to draw
295
- // snapshot.destinations → the Page for each visible region
296
- return renderYourFramework(snapshot);
297
- }
298
- ```
166
+ Each equal target still receives its own EntryId and draft. ResourceKey may share explicitly cached query data without sharing view state. Standard browser start owns URL actions, redirects and popstate; obsolete URL results cannot commit over later work. Explicit tree operations use one serialized controller queue. Tabs retain branches, Stack retains present entries, Split guards each destination. Choose renderer mode `entries` when native entry lifetimes should follow the tree.
299
167
 
300
- For the concrete islands shell that `renderApp` builds — chrome + per-destination islands as independent hydration roots, plus the client-side `mountEntry` / `resolveIslandsShell` that adopt and hydrate them — see [Islands SSR](./04-rendering-and-hydration.md#islands-ssr-structured-architecture-approach-c).
168
+ ## Navigation actions
301
169
 
302
- How it works under the hood: each visible destination is serialized through the **existing** `PrefetchedIntents` channel as a normal `{ intent, data: page }` entry, plus one sentinel entry carrying the serialized tree. `@finesoft/server` needs **zero changes** — it transports the sentinel through the same `#serialized-server-data` script. On hydration the browser bridge reads the tree back from history state (or the sentinel) and reuses the prefetched pages.
170
+ All navigation uses `app.perform(action)`. URLs use `{ kind: "flow", url }`; structured navigation uses these actions:
303
171
 
304
- If a request has no structural deep-link and your app provides no skeleton, SSR falls back to `Router.resolve(url)` → a single leaf — i.e. today's flat single page, including its `renderMode`. The 404 path is unchanged.
172
+ | kind | Fields |
173
+ | ----------------- | ------------------------------------------------------------- |
174
+ | push / replaceTop | intent, params?, query?, target?, url? |
175
+ | pop | count?, target? |
176
+ | popToRoot | target? |
177
+ | popTo | index, target? |
178
+ | selectTab | key, target? |
179
+ | selectColumn | columnId, intent (undefined clears), params?, query?, target? |
180
+ | setVisibility | visibility, target? |
181
+ | reuseEntry | entryId |
182
+ | refresh | — |
183
+ | hydrate | tree |
305
184
 
306
- ## Deep-linking with `createFullStateCodec`
185
+ ## Transaction policies
307
186
 
308
- By default, the **active leaf** drives the URL (`/posts/7`) and the full tree travels via history state clean, shareable URLs for the focused destination. To encode the **entire** tree into the URL for full deep-linking (sharing a link that restores tabs, stack depth, and split selections), opt into `createFullStateCodec`:
187
+ `defineWebApp({ beforeNavigate, beforeCommit })` adds optional whole-tree policies. Each policy in its array runs once per transaction, even when the target tree is empty. Definition policies run before controller-local policies. `beforeLoad` / `afterLoad` still run for each visible page.
309
188
 
310
189
  ```ts
311
- import { createFullStateCodec } from "@finesoft/front";
312
-
313
- export const navigation = defineNavigation({
314
- initial: tabs({
315
- active: "home",
316
- branches: { home: stack(leaf("home")), me: stack(leaf("me")) },
317
- }),
318
- codec: createFullStateCodec(), // whole tree → "?__nav=..." query param
319
- });
190
+ import { next, deny, type BeforeCommitPolicy } from "@finesoft/front";
191
+ import { hasUnsavedDraft } from "./editor-state";
192
+ export const preserveDraft: BeforeCommitPolicy = ({ from, candidate }) => {
193
+ // Inspect application-owned draft state and both trees here.
194
+ const leavingEditor =
195
+ from.destinations.some((page) => page.intent === "editor") &&
196
+ !candidate.destinations.some((page) => page.intent === "editor");
197
+ return leavingEditor && hasUnsavedDraft()
198
+ ? deny(409, "Save your draft before leaving")
199
+ : next();
200
+ };
201
+ // defineWebApp({ ..., beforeCommit: [preserveDraft] })
320
202
  ```
321
203
 
322
- Now URLs look like `/me?__nav=<encoded-tree>`, and pasting one restores the complete navigation state on both SSR and the browser. The encoding is compact (base64url), stable (sorted keys, so the same tree always yields the same string), and lossless. Pass `createFullStateCodec({ param: "nav" })` to rename the reserved query parameter.
323
-
324
- You can also implement a custom `NavigationCodec` if you need a bespoke URL scheme — both built-ins only depend on the router's `getRoutes()` (and optional `reverse()`), nothing more.
325
-
326
- ## Guards still work
327
-
328
- Navigation-level `beforeLoad` / `afterLoad` guards run for the **primary** destination (the active leaf) on every navigation, with the same `redirect` / `rewrite` / `deny` semantics as [chapter 3](./03-middleware.md):
329
-
330
- ```ts
331
- export const navigation = defineNavigation({
332
- initial: tabs({
333
- active: "home",
334
- branches: { home: stack(leaf("home")), me: stack(leaf("me")) },
335
- }),
336
- beforeLoad: [authGuard],
337
- });
338
- ```
339
-
340
- - `redirect` → handled as an in-app navigation (the browser reuses the FlowAction pipeline); the target isn't dispatched.
341
- - `rewrite` → the new URL is re-resolved into the destination's intent/params.
342
- - `deny` → the destination is marked with the deny status and its intent is not dispatched.
343
-
344
- A single destination's dispatch failure never throws out of an operation — it records a `status` and a fallback page on that destination (the same `fallback` safety net as controllers), so one failing split column can't blank the whole screen.
345
-
346
- ## Backward compatibility
204
+ Admission receives `from`, candidate `tree`, stable `transitionId`, active `execution`, its `signal`, and `isServer`. It returns `next`, `deny`, or `redirect`. Page redirects keep the same transaction identity and do not repeat admission. Commit policies also receive the loaded `candidate`; they return only `next` or `deny`, before cache consumption, committed state, history, events or views change. Each redirect hop retains its existing execution cleanup; the final commit policy sees the final hop's execution. Async policies must honor cancellation for their own I/O; stale results cannot commit.
347
205
 
348
- - Apps that don't pass `navigation` to `startBrowserApp` / `createSSRRender` run the **original flat path** with zero behavior change.
349
- - A single-leaf tree is equivalent to the flat single page: one visible destination, one resolve/dispatch, one before/after pass. SSR only adds the tree sentinel to `serverData` (stripped before it reaches `PrefetchedIntents`).
350
- - `Page` stays content-agnostic. Navigation adds structure _around_ your pages; it never dictates their shape or how you render them.
206
+ A denial returns an uncommitted snapshot with `rejection`, including for empty trees. Session restore rejects it before replacing scopes or slices. Initial browser denial renders the error without committing navigation or recording a page visit; later denial preserves the displayed draft. Both flat and navigation SSR honor these policies and emit no rejected page data or public-cache permission. CSR shells still defer navigation to the browser.
351
207
 
352
- ## Next
208
+ Owned back/forward rejection compensates to the committed history entry and restores its scroll identity. Additive history metadata preserves ownership and position across reloads. Entries without compatible ownership metadata are diagnosed; the framework cannot infer a safe traversal distance for external entries.
353
209
 
354
- - [Middleware](./03-middleware.md) the guard semantics navigation reuses
355
- - [Rendering & hydration](./04-rendering-and-hydration.md) — the render-mode × architecture matrix, the islands SSR shell, and how prefetched results cross the SSR → CSR boundary
210
+ Low-level hosts configuring `createWebSession({ createContext })` return a `NavigationContext` directly. The execution supplies its DI container and cancellation signal; request cookies and headers stay in the host context. The session uses the application's `getErrorPage` unless explicitly overridden.