@finesoft/front 0.5.0 → 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 -698
  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-BYZq9Jp7.mjs +0 -2
  128. package/dist/browser-JTs2jqVY.d.mts +0 -2811
@@ -1,219 +1,11 @@
1
- # 12. Session restoration
1
+ # Session restoration and multiple instances
2
2
 
3
- The framework already restores the **first screen**: SSR injects the prefetched intent results through `PrefetchedIntents`, and the browser reuses them on the first navigation. Structured navigation also carries the current tree across back/forward via `history.state`.
3
+ Create embedded apps with `createBrowserApp({ definition, target, history: "memory", persistenceKey: "first", session: {}, domRestore: true })`. Each instance needs its own target and stable persistence key. Only one app per window may own browser history.
4
4
 
5
- But one class of state survives **none** of that: what the user was actually _doing_ when they **hard-reloaded, crashed the tab, or closed and came back** which screen (or stack depth, or tab, or split column) they were on, the half-typed draft in a form, how far a list was scrolled. The in-memory `history.state` map is wiped by a full reload; `PrefetchedIntents` only covers the one server-rendered screen.
5
+ Mount the native App, then await `app.ready`. Session reading/restoration starts after the first native commit acknowledgement; Outlet's commit never waits for restore. Register React persistence providers in a layout effect, and Vue/Svelte providers during native mount. `app.session.register(provider)` returns an unregister callback. Provider capture/restore accesses native business state directly; no separate NameStore is required.
6
6
 
7
- **Session restoration** fills that gap: it serializes a versioned, JSON-safe **session snapshot** (navigation position + app-registered state slices + navigation-scoped per-screen state) to a pluggable `Storage`, and rehydrates it on a fresh load. The framework ships **no UI** it restores **state**, and your app re-renders from it however you like.
7
+ Hidden pages retain native instances. Pop removes the entry and scoped draft. DOM restoration handles explicitly marked fields and scroll within `data-restore-root`, bounded by the owning app. Instances do not capture one another's inputs.
8
8
 
9
- It is entirely opt-in: an app that never passes `session` to `startBrowserApp` is **byte-for-byte unchanged**.
9
+ `app.session.save()` and `clear()` return observable results. Adjacent unstarted implicit saves coalesce; explicit snapshots, load, restore and clear are ordered boundaries. Disposal captures the current state and waits for registered storage work. Browser shutdown still cannot guarantee an asynchronous write finishes.
10
10
 
11
- ## The two scopes
12
-
13
- A snapshot captures two layers of state, serialized together and restored together across a reload:
14
-
15
- | Scope | Lives in | Keyed by | Lifetime | SwiftUI analogue |
16
- | --------------------------- | -------- | -------------- | ----------------------------------------------------------------------- | ---------------- |
17
- | **Global slices** | `slices` | `provider.key` | The whole session (theme, a cross-screen wizard draft…) | `@SceneStorage` |
18
- | **Navigation-scoped state** | `scoped` | `entryKey` | Bound to one navigation entry — dropped when that entry leaves the tree | `@State` |
19
-
20
- - **Global slices** are app-wide. You register a `SessionStateProvider` per slice; the framework orchestrates _when_ it is captured and persisted. It never interprets the contents — it only moves them.
21
- - **Navigation-scoped state** is bound to a _navigation entry_, mirroring the position-scoped lifecycle of a SwiftUI view's `@State` (covered below).
22
-
23
- ## Global slices: `SessionStateProvider`
24
-
25
- An app registers one provider per slice. `capture()` returns a JSON-safe synchronous value; `restore(data)` puts it back (your app calls `setState` / refills the form / scrolls):
26
-
27
- ```ts
28
- import type { SessionStateProvider } from "@finesoft/front";
29
-
30
- const themeSlice: SessionStateProvider<string> = {
31
- key: "theme",
32
- capture: () => getCurrentTheme(),
33
- restore: (theme) => applyTheme(theme),
34
- };
35
- ```
36
-
37
- The framework moves the value verbatim and never inspects it — so **you** decide what to capture. Exclude sensitive fields right here in `capture()`; a slice you never register is never captured.
38
-
39
- ## Navigation-scoped state: the SwiftUI `@State` lifecycle
40
-
41
- Navigation-scoped state is the interesting half. It is keyed by **entry identity**, not by visibility, and it follows the same position-scoped lifecycle as a SwiftUI view's `@State`:
42
-
43
- > `A` → push `B` → go back (pop `B`) to `A`: **`B`'s state is discarded, `A`'s state is still there.**
44
-
45
- The mechanism: each entry's state bag is stored under `entryKey = intent + " " + stableStringify(params)` — the same identity the navigation controller uses for a destination, so it is **stable across a reload**. After every committed navigation, the framework **prunes** the scoped map down to the entries **actually present in the tree** — note _present_, not _visible_. Any key whose entry is no longer in the tree is dropped.
46
-
47
- ```ts
48
- import { sessionEntryKey } from "@finesoft/front";
49
-
50
- // When you render a screen, read/write its scoped bag with the entry's key:
51
- const key = sessionEntryKey("post", { id: 7 });
52
- store.scope.set(key, { scroll: 240, draft: "half a comment" });
53
- const bag = store.scope.get(key); // -> { scroll: 240, draft: "..." } | undefined
54
- ```
55
-
56
- Walking through the lifecycle:
57
-
58
- - **push `B`** → tree `[A, B]`, present `{A, B}` → `A`'s state is **kept** (`A` is still on the stack, just not visible) and `B` gets its own scope.
59
- - **pop `B`** → tree `[A]`, present `{A}` → **`B`'s scope is pruned away**, `A`'s is kept intact; going back to `A` renders with its retained state.
60
- - **switch a TabView tab** → the other branches are still in the tree → their state is kept alive (exactly like SwiftUI keeping inactive tabs mounted).
61
- - **across a reload** → `scoped` is serialized into the snapshot; after reload, every entry still in the tree gets its scope back, and a later pop discards it as usual.
62
-
63
- `store.scope` is the `NavigationScopedState` instance held by the store — `get` / `set` / `delete` / `keys`, plus the `prune(presentKeys)` the framework calls for you. With the high-level `startBrowserApp({ session })` path you don't hold the store directly: the `SessionHandle` handed to your `mount` callback (context) exposes the same instance as `handle.scope` (still live after a restore rebuilds it), so you `handle.scope.get(entryKey)` / `set(entryKey, data)` the same way.
64
-
65
- ### Flat vs structured: retention _is_ a stack
66
-
67
- That "keep `A` under `B`, drop `B` on pop, restore `A`" behavior is, by definition, **stack semantics** — so it only exists in **structured navigation**, where a stack/tree can hold entries that are _present but not visible_.
68
-
69
- A **flat single page has no stack**: `A → B` is a full-page replacement, so `presentKeys()` is always a single entry (the current URL). The moment you leave a screen its scope is pruned, and a browser **Back** re-renders it fresh.
70
-
71
- Both modes support "current-screen scope + restore-across-reload". If you want "go Back and keep the previous screen", build it as a structured stack — push instead of replace. That is precisely what `NavigationStack` is _for_; it is not a shortcoming of flat mode.
72
-
73
- ## The snapshot
74
-
75
- `createSessionStore(options)` returns the `SessionStore` orchestrator. `capture()` assembles a snapshot without persisting; the snapshot model is:
76
-
77
- ```ts
78
- interface SessionSnapshot {
79
- readonly version: number;
80
- readonly navigation?: SerializedNavigation | SessionUrlLocation; // structured tree | { url }
81
- readonly slices: Readonly<Record<string, unknown>>; // provider.key -> capture()
82
- readonly scoped: Readonly<Record<string, unknown>>; // entryKey -> state bag
83
- readonly capturedAt: number; // epoch ms, for maxAgeMs expiry
84
- }
85
- ```
86
-
87
- `navigation` is discriminated with a light guard: a `SerializedNavigation` always carries a `kind` (leaf/stack/tabs/split); a flat `SessionUrlLocation` carries a `url`. `isUrlLocation(nav)` tells them apart.
88
-
89
- The store exposes:
90
-
91
- ```ts
92
- interface SessionStore {
93
- register(provider: SessionStateProvider): () => void; // returns a disposer
94
- readonly scope: NavigationScopedState;
95
- capture(): SessionSnapshot; // assemble (nav + slices + scoped), no I/O
96
- persist(snapshot?: SessionSnapshot): void; // capture() if omitted, then write
97
- load(): SessionSnapshot | undefined; // read + validate (version / maxAge / shape)
98
- restore(snapshot?: SessionSnapshot): void | Promise<void>; // load() if omitted, then apply
99
- clear(): void; // remove the persisted snapshot
100
- save(): void; // capture + persist — the manual escape hatch
101
- }
102
- ```
103
-
104
- `load()` discards a snapshot whose version mismatches, whose `capturedAt` is older than `maxAgeMs`, or whose shape is malformed — it returns `undefined` rather than ever throwing into your app. A provider that throws in `capture()` / `restore()` is isolated: its slice is skipped, the error goes to `onError`, and the rest of the snapshot survives.
105
-
106
- ## Persistence: `sessionStorage` by default, swappable
107
-
108
- The snapshot is encoded with a stable stringify and written as a single `storage.set(key, ...)`. `Storage` is the existing core dependency interface, so durability is **your** choice:
109
-
110
- ```ts
111
- import { createWebStorage } from "@finesoft/front";
112
-
113
- createWebStorage("session"); // sessionStorage — tab-scoped, cleared when the tab closes (default)
114
- createWebStorage("local"); // localStorage — survives across tabs and restarts
115
- ```
116
-
117
- `createWebStorage` maps `get`/`set`/`delete` onto `getItem`/`setItem`/`removeItem`, swallows quota errors on write (session restoration is best-effort — it never interrupts navigation), and degrades to a safe no-op when the chosen Web Storage is unavailable (e.g. private mode `SecurityError`).
118
-
119
- Because it is just the `Storage` interface, you can supply **any** implementation — an in-memory store for tests, or a server-synced `Storage` for cross-device restoration. The framework v1 ships no built-in server endpoint, but the seam is open.
120
-
121
- ## Wiring it into the browser
122
-
123
- Pass an optional `session` to `startBrowserApp`. When present, the framework builds a `SessionStore`, registers your providers, wires a `SessionBridge` (auto-capture on navigation + `pagehide`/`visibilitychange`), runs the boot restore after the first navigation, and hands you a `SessionHandle`:
124
-
125
- ```ts
126
- // src/main.ts
127
- import { startBrowserApp } from "@finesoft/front";
128
- import { bootstrap } from "./bootstrap";
129
- import { themeSlice, draftSlice } from "./lib/session";
130
-
131
- startBrowserApp({
132
- bootstrap,
133
- callbacks,
134
- session: {
135
- providers: [themeSlice, draftSlice],
136
- // storage defaults to createWebStorage("session")
137
- maxAgeMs: 1000 * 60 * 60 * 24, // discard snapshots older than a day (optional)
138
- },
139
- mount(target, { session, app }) {
140
- // session: SessionHandle (save/clear/scope/...); app: unified nav+session handle.
141
- // Auto-capture/restore already run; use session.save() / session.clear() as escape hatches.
142
- // ... mount your UI; pass `app` (or `session`) to components ...
143
- return () => undefined;
144
- },
145
- });
146
- ```
147
-
148
- When `session` is **absent**, none of this runs and the original `startBrowserApp` path is byte-for-byte unchanged.
149
-
150
- ### Flat vs structured wiring (automatic)
151
-
152
- `startBrowserApp` picks the navigation adapter for you:
153
-
154
- - **With** a `navigation` config → the structured `createNavigationSessionAdapter(controller)`: it serializes the whole tree, and on restore `hydrate`s it back. Auto-capture is driven by the navigation handle's `subscribe`.
155
- - **Without** `navigation` (flat single page) → the `createUrlSessionAdapter` bound to `framework.perform(makeFlowAction(url))`: it captures `{ url }` and navigates on restore.
156
-
157
- You only choose the adapter directly if you are assembling the store yourself (e.g. on the server, or in tests).
158
-
159
- ## The handle: manual save / clear / dispose
160
-
161
- The `SessionHandle` (delivered in the mount context) gives you the escape hatches — auto-capture already runs, but you can force a write, clear the snapshot, or tear everything down. The unified `app` handle merges navigation commands with session `save`/`clear`/`scope`, so components can hold a single object instead of assembling their own controller:
162
-
163
- ```ts
164
- interface SessionHandle {
165
- restore(currentUrl: string): void | Promise<void>; // boot restore (already called for you)
166
- save(): void; // force an immediate persist
167
- clear(): void; // drop the persisted snapshot (e.g. on logout)
168
- dispose(): void; // unsubscribe navigation + remove pagehide/visibilitychange + clear timers
169
- }
170
- ```
171
-
172
- Call `handle.clear()` on logout so the next user doesn't inherit a stale session; call `handle.dispose()` if you tear down the app instance yourself.
173
-
174
- ### When does it capture?
175
-
176
- You rarely call `save()` — capture is automatic:
177
-
178
- - **On navigation change**: the bridge first prunes the scoped map to `adapter.presentKeys()` (this is where "pop `B` drops `B`'s state" actually lands), then **debounces** a write (default `SESSION_DEFAULT_DEBOUNCE_MS` = 500 ms, coalescing rapid navigations). Tune with `session.debounceMs`.
179
- - **On `pagehide` and `visibilitychange` (hidden)**: it persists **immediately** and cancels any pending debounce — more reliable than `beforeunload` on mobile (the last state is captured before the tab is backgrounded or reclaimed).
180
-
181
- ## Deep-link policy: `shouldRestore`
182
-
183
- On boot the bridge reads the snapshot and applies it **only if** `shouldRestore(snapshot, currentUrl)` passes — a single boolean gate for the whole `nav + slices` restore. The default, `defaultShouldRestore`, honors **explicit deep links over a stale session**:
184
-
185
- | Snapshot `navigation` | Restores when… |
186
- | --------------------------------------- | ---------------------------------------------------------------------------- |
187
- | **Flat** (`SessionUrlLocation`) | `currentUrl === snapshot.navigation.url` **or** the current path is root `/` |
188
- | **Structured** (`SerializedNavigation`) | the current path is root `/` |
189
- | **None** (slices only) | always (URL-independent) |
190
-
191
- So reloading the same page (or entering fresh at `/`) restores; opening a different deep link `/x` does **not** get overwritten by an old session. "Root" is the path `=== "/"` (query/hash stripped). Apps served under a base path should override the gate:
192
-
193
- ```ts
194
- session: {
195
- providers: [themeSlice],
196
- shouldRestore: (snapshot, currentUrl) => currentUrl.startsWith("/app/"),
197
- }
198
- ```
199
-
200
- Restoring to a different state than the SSR'd URL produces one client-side jump (SSR renders the URL's screen, then the client restores). That timing is exposed through the bridge so you can control it; a pure-CSR app can restore before first paint and avoid it entirely.
201
-
202
- ## What is _not_ captured
203
-
204
- - **DOM you didn't register.** The framework never scans the DOM. State slices are whatever your providers `capture()` — nothing more.
205
- - **Anything when you register no providers.** With only navigation (or nothing) registered, capture is effectively zero — the privacy default.
206
- - **Sensitive fields you exclude.** `capture()` is your filter; strip tokens, PII, and the like there.
207
- - **A stale, expired, or malformed snapshot.** `load()` returns `undefined` instead of crashing the app to restore a bad state.
208
-
209
- ## Backward compatibility
210
-
211
- - An app that doesn't pass `session` to `startBrowserApp` runs the **original path** with zero behavior change — the entire feature is gated behind that one field.
212
- - Session restoration adds no requirement on the server. A server-synced snapshot is possible by supplying your own `Storage`, but nothing is built in.
213
- - The framework restores **state**, never UI. Your `Page` models and how you render them are untouched.
214
-
215
- ## Next
216
-
217
- - [Navigation](./11-navigation.md) — the structured tree whose entries scope per-screen state
218
- - [Rendering & hydration](./04-rendering-and-hydration.md) — how the first screen is already restored via prefetched results
219
- - [DI container](./07-di-container.md) — the `Storage` dependency that session restoration persists through
11
+ Clean up with `try { await app.dispose(); } finally { nativeRoot.unmount(); }`, using Svelte's `unmount` function when applicable. Other instances can keep calling `other.perform({ kind: "flow", url: "/" })`. Session protocol v2 uses a navigation tree; old URL-only snapshots are incompatible. Business slices retain independent versions and migration contracts.
package/docs/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > **Language:** English (this page) · **[简体中文](./zh/README.md)**
4
4
 
5
- Full-stack TypeScript framework — router, DI, actions, SSR, and server in one package. Works with **Vue**, **React**, or **Svelte**. Deploys to Node.js, Vercel, Cloudflare Workers, Netlify, or static hosting.
5
+ Portable TypeScript execution, Web, browser, SSR, HTTP and platform APIs through `@finesoft/front`. Standard UI adapters support React, Vue and Svelte. Local artifact tests distinguish Node, workerd and browser Worker from an actual deployment.
6
6
 
7
7
  ## Three entry points
8
8
 
@@ -20,14 +20,14 @@ A linear path. Each chapter assumes the previous one. By the end you can build,
20
20
  6. [HTTP client](./06-http-client.md) — `HttpClient` subclassing, interceptors, `HttpError`
21
21
  7. [DI container](./07-di-container.md) — registration, scopes, `DEP_KEYS`, dispose
22
22
  8. [Observability](./08-observability.md) — `Logger`, `EventRecorder`, impression tracking, `ReportCallback`
23
- 9. [Server & deployment](./09-server-and-deployment.md) — `createServer`, proxy, adapters, Vite plugin
23
+ 9. [Server & deployment](./09-server-and-deployment.md) — data endpoints, Node/Worker hosts, adapters, Vite plugin
24
24
  10. [Features, platform, PWA](./10-features-platform-pwa.md) — feature flags, platform detection, PWA mode
25
25
 
26
26
  ### Engineer working on an existing app — jump to practice
27
27
 
28
28
  Cross-cutting concerns and conventions. Read after you understand the basics.
29
29
 
30
- - [Project structure](./engineering/project-structure.md) — recommended layout, `bootstrap.ts` splitting, single source of truth
30
+ - [Project structure](./engineering/project-structure.md) — recommended layout, application definitions and view bindings, single source of truth
31
31
  - [Testing](./engineering/testing.md) — controllers, middleware, scoped DI, mocking the framework
32
32
  - [CI & release flow](./engineering/ci-release-flow.md) — changesets, the bundled release workflow, version reconciliation
33
33
 
@@ -44,7 +44,7 @@ Each entry is **symptom → root cause → fix**, kept short.
44
44
 
45
45
  ### Extending the framework — advanced recipes
46
46
 
47
- Each recipe is a complete, runnable extension example with explanation.
47
+ Recipes explain supported public contracts and show application snippets; platform extensions require their own runtime verification.
48
48
 
49
49
  - [Custom action handler](./advanced/custom-action-handler.md) — beyond `FlowAction` / `ExternalUrlAction`
50
50
  - [Custom event recorder](./advanced/custom-event-recorder.md) — wire Sentry / Datadog / your own pipeline
@@ -57,16 +57,16 @@ Each recipe is a complete, runnable extension example with explanation.
57
57
  ```
58
58
  URL/Action → Router.resolve()
59
59
  → beforeLoad chain (NavigationContext: redirect/rewrite/deny/next)
60
- IntentDispatcher (controller.execute() → Page; fallback() on error)
60
+ RuntimeHandle (controller.execute() → Page; fallback() on error)
61
61
  → afterLoad chain (PostLoadContext)
62
62
  → render (SSR: HTML + serialized PrefetchedIntents; CSR: shell)
63
63
  ```
64
64
 
65
- The same `bootstrap()` runs on the server and in the browser. SSR serializes prefetched intent results into HTML; the browser deserializes them into `PrefetchedIntents` so the first client navigation reuses server results without a refetch.
65
+ The same application declaration is used by server and browser hosts. SSR serializes prefetched intent results into HTML; the browser deserializes them into `PrefetchedIntents` so the first client navigation reuses server results without a refetch.
66
66
 
67
67
  ## Conventions used in these docs
68
68
 
69
- - **Code blocks** are runnable as written unless a comment says otherwise.
69
+ - **Code blocks** show contracts and integration snippets; application components and local variables come from the surrounding example.
70
70
  - **File paths** are relative to the project root (the directory containing `vite.config.ts`).
71
71
  - **`vp`** is the [Vite+](https://github.com/voidzero-dev/setup-vp) CLI. Use it instead of calling `pnpm` / `npm` / `vitest` / `tsdown` directly.
72
72
  - **`@finesoft/front`** is the only import surface for application code. Internal packages (`core`, `browser`, `ssr`, `server`) are bundled in and not published.
@@ -1,248 +1,48 @@
1
- # Advanced: custom action handler
1
+ # Application actions
2
2
 
3
- The framework ships three action kinds: `flow` (in-app navigation), `external-url` (full browser navigation), and `compound` (a tuple of actions executed in order). For most apps these are enough.
3
+ The WebSession is the ActionDispatcher and navigation state owner. Native views, intercepted links and custom handlers share `app.perform(action)`. Actions cover both URL navigation and structured Stack, Tab and Split changes; there is no separate navigation command object.
4
4
 
5
- This recipe shows how to add your own useful when you have a class of operations that need cross-cutting handling (analytics, confirmations, telemetry) without polluting every callsite.
5
+ `FlowAction` loads a URL. Without an explicit navigation tree or codec, the departing page is released; structured apps retain their existing branches. Navigating to the current URL reloads its existing entry. An explicit `push` creates a new entry, including for an equal target.
6
6
 
7
- ## Use case: confirmation-gated action
7
+ `perform` resolves after guards, data loading, commit and native acknowledgement. It returns the resulting navigation snapshot; a rejected tree action returns an uncommitted snapshot with `rejection`. Compound actions execute in order and stop on rejection or failure. An optional second argument `{ signal }` reaches guarded page loading.
8
8
 
9
- We'll add a `"confirm"` action kind: dispatch it with `{ kind: "confirm", message, then }`, and the framework shows a confirmation dialog before dispatching `then` (which is itself an action).
9
+ `app.onAction(kind, handler)` and `app.removeAction(kind)` register or explicitly replace handlers on the same executor. Keep business data operations on `app.runtime.execute`. ExternalUrlAction opens a new window with `noopener,noreferrer`; `{ kind: "reuseEntry", entryId }` reveals a retained instance without requiring a second URL.
10
10
 
11
- ```ts
12
- dispatch({
13
- kind: "confirm",
14
- message: "Delete this item permanently?",
15
- then: { kind: "flow", url: "/items/42/deleted" },
16
- });
17
- ```
18
-
19
- The user clicks Cancel → no navigation. Clicks OK → the inner flow action fires.
20
-
21
- ## Step 1: define the action type
22
-
23
- ```ts
24
- // src/lib/actions/confirm.ts
25
- import { type Action } from "@finesoft/front";
26
-
27
- export interface ConfirmAction {
28
- kind: "confirm";
29
- message: string;
30
- then: Action;
31
- }
32
-
33
- export function makeConfirmAction(message: string, then: Action): ConfirmAction {
34
- return { kind: "confirm", message, then };
35
- }
36
-
37
- export function isConfirmAction(action: Action): action is ConfirmAction {
38
- return (action as any).kind === "confirm";
39
- }
40
- ```
41
-
42
- The shape is yours — `kind` just has to be unique among registered handlers.
43
-
44
- ## Step 2: extend the `Action` type union
45
-
46
- TypeScript doesn't auto-expand the framework's `Action` type. Declare a module augmentation:
47
-
48
- ```ts
49
- // src/lib/actions/confirm.ts
50
- declare module "@finesoft/front" {
51
- interface ActionRegistry {
52
- confirm: ConfirmAction;
53
- }
54
- }
55
- ```
56
-
57
- If the framework exposes `ActionRegistry` (most pluggable frameworks do), this lets TypeScript know about your new kind. If it doesn't, cast at registration time:
58
-
59
- ```ts
60
- framework.actionDispatcher.register("confirm" as any, handleConfirm as any);
61
- ```
62
-
63
- The runtime doesn't care — `kind` is a plain string at dispatch time.
64
-
65
- ## Step 3: write the handler
66
-
67
- ```ts
68
- // src/lib/actions/confirm.ts
69
- import type { Framework } from "@finesoft/front";
70
-
71
- export function registerConfirmHandler(framework: Framework): void {
72
- framework.actionDispatcher.register("confirm", async (action: ConfirmAction) => {
73
- if (typeof window === "undefined") {
74
- // SSR: confirmation isn't possible — fall through to the inner action
75
- await framework.actionDispatcher.dispatch(action.then);
76
- return;
77
- }
78
-
79
- const confirmed = window.confirm(action.message);
80
- if (!confirmed) return;
81
-
82
- await framework.actionDispatcher.dispatch(action.then);
83
- });
84
- }
85
- ```
86
-
87
- Key points:
88
-
89
- - The handler runs on both server and client. On the server `window` doesn't exist — decide what "no UI" means for your action.
90
- - Recursive dispatch (`actionDispatcher.dispatch(action.then)`) goes through the regular pipeline, including any other custom handlers.
91
- - The framework already protects compound actions with a recursion-depth limit (default 4). Your handler is reached via dispatch, so it inherits that limit.
92
-
93
- ## Step 4: register at app startup
94
-
95
- ```ts
96
- // src/main.ts
97
- import { startBrowserApp } from "@finesoft/front/browser";
98
- import { bootstrap } from "./bootstrap";
99
- import { registerConfirmHandler } from "./lib/actions/confirm";
100
-
101
- startBrowserApp({
102
- bootstrap,
103
- onBeforeStart(framework) {
104
- registerConfirmHandler(framework);
105
- },
106
- mount: /* ... */,
107
- });
108
- ```
109
-
110
- Mirror on the SSR side:
11
+ A handler receives `(action, invocation)`. When it delegates to another action, pass the same invocation to `app.perform(nextAction, invocation)` so the entire sequence shares cancellation. New URL navigation cancels earlier groups; structured edits remain serialized and invalidate an older URL still resolving. Modal and external actions do not replace background navigation.
111
12
 
112
- ```ts
113
- // src/ssr.ts
114
- export const render = createSSRRender({
115
- bootstrap,
116
- onBeforeStart(framework) {
117
- registerConfirmHandler(framework);
118
- },
119
- async renderApp(page) {
120
- /* ... */
121
- },
122
- });
123
- ```
124
-
125
- Or, simpler: register inside `bootstrap()` so both sides get it automatically.
126
-
127
- ## Step 5: use it
128
-
129
- ```ts
130
- // In a view component
131
- import { makeConfirmAction, makeFlowAction } from "@finesoft/front";
132
-
133
- function onDelete(id: string) {
134
- framework.actionDispatcher.dispatch(
135
- makeConfirmAction(`Delete item ${id}?`, makeFlowAction(`/items/${id}/deleted`)),
136
- );
137
- }
138
- ```
139
-
140
- ## Replacing an existing handler
141
-
142
- Each `kind` can be registered exactly once. The dispatcher warns on duplicate registrations and skips:
143
-
144
- ```ts
145
- framework.actionDispatcher.register("flow", myFlowHandler);
146
- // [ActionDispatcher] kind="flow" already registered, skipping
147
- ```
148
-
149
- To replace, unregister first:
150
-
151
- ```ts
152
- framework.actionDispatcher.removeAction("flow");
153
- framework.actionDispatcher.register("flow", myFlowHandler);
154
- ```
155
-
156
- Useful when you want to wrap the default flow handler with logging or analytics:
13
+ Browser navigation and guard redirects accept HTTP(S). ExternalUrlAction also accepts `mailto:` and `tel:`. Invalid URLs and executable or unsupported protocols are rejected before browser handoff; applications requiring a custom protocol can explicitly replace that action handler.
157
14
 
158
- ```ts
159
- import { registerFlowActionHandler, type FlowActionDependencies } from "@finesoft/front";
160
-
161
- const baseHandler = framework.actionDispatcher.getHandler("flow"); // hypothetical
162
- framework.actionDispatcher.removeAction("flow");
163
- framework.actionDispatcher.register("flow", async (action) => {
164
- console.log("[nav]", action.url);
165
- await baseHandler(action);
166
- });
167
- ```
168
-
169
- In practice, prefer middleware (`beforeLoad`) for cross-cutting concerns on navigation — replacing the flow handler is invasive.
170
-
171
- ## Compound actions with custom kinds
172
-
173
- `CompoundAction` works with any registered kind:
15
+ ## URL and structured actions
174
16
 
175
17
  ```ts
176
- framework.actionDispatcher.dispatch({
177
- kind: "compound",
178
- actions: [
179
- makeFlowAction("/checkout/complete"),
180
- makeConfirmAction("Add to email list?", { kind: "subscribe", email: user.email }),
181
- ],
182
- });
18
+ await app.perform({ kind: "flow", url: "/items/42" });
19
+ await app.perform({ kind: "push", intent: "item", params: { id: 42 } });
20
+ await app.perform({ kind: "selectTab", key: "favorites" });
21
+ await app.perform({ kind: "pop" });
22
+ await app.perform({ kind: "refresh" });
183
23
  ```
184
24
 
185
- Each inner action runs sequentially. A handler that throws short-circuits the remaining actions in the compound — wrap with `try/catch` if you want best-effort semantics.
186
-
187
- ## Server-side considerations
25
+ ## Migrating existing consumers
188
26
 
189
- Action handlers run on the server during SSR if the controller dispatches them. Common patterns:
27
+ | Previous API | Replacement |
28
+ | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
29
+ | `app.navigation.navigate(url)` | `app.perform({ kind: "flow", url })` |
30
+ | `app.navigation.push(intent, params)` | `app.perform({ kind: "push", intent, params })` |
31
+ | `controller.apply(operation)` | `controller.perform(action)` with the same structured fields |
32
+ | `controller.resolve()` at startup | `controller.start()` |
33
+ | `app.actionDispatcher.onAction/removeAction` | `app.onAction/removeAction` |
34
+ | `FlowAction.entryId` | `{ kind: "reuseEntry", entryId }` |
35
+ | `NavigationHandle`, `SessionHandle`, `SessionAccess` | `NavigationBridge` for cleanup; `SessionStore` for persistence; `BrowserSession.restoreFromUrl` for browser boot |
190
36
 
191
- - **External URLs**: the server can't navigate the user most apps return early. The framework's built-in `external-url` handler does exactly that on SSR.
192
- - **Confirm-style**: no user to ask. Either auto-accept (use the inner action) or auto-reject (drop it).
193
- - **Telemetry-only**: works the same on both sides. Just record.
37
+ The old navigation commands and forwarding methods are removed. All other tree operations use the corresponding Action kind. Pure tree-building functions remain available for immutable composition.
194
38
 
195
- If your handler depends on browser APIs that don't exist on the server, gate with `typeof window === "undefined"`.
39
+ ## Modal presentation
196
40
 
197
- ## Testing
41
+ Pass `onModal(page, { app, snapshot })` to `createBrowserApp`, then call:
198
42
 
199
43
  ```ts
200
- import { afterEach, describe, expect, test, vi } from "vite-plus/test";
201
- import { Framework } from "@finesoft/front";
202
- import { registerConfirmHandler, makeConfirmAction } from "./confirm";
203
-
204
- describe("confirm action", () => {
205
- afterEach(() => vi.restoreAllMocks());
206
-
207
- test("dispatches inner action when confirmed", async () => {
208
- const framework = Framework.create({});
209
- registerConfirmHandler(framework);
210
- vi.stubGlobal("window", { confirm: () => true });
211
-
212
- const innerHandler = vi.fn();
213
- framework.actionDispatcher.register("test", innerHandler);
214
-
215
- await framework.actionDispatcher.dispatch(
216
- makeConfirmAction("ok?", { kind: "test" } as any),
217
- );
218
-
219
- expect(innerHandler).toHaveBeenCalled();
220
- });
221
-
222
- test("skips inner action when cancelled", async () => {
223
- const framework = Framework.create({});
224
- registerConfirmHandler(framework);
225
- vi.stubGlobal("window", { confirm: () => false });
226
-
227
- const innerHandler = vi.fn();
228
- framework.actionDispatcher.register("test", innerHandler);
229
-
230
- await framework.actionDispatcher.dispatch(
231
- makeConfirmAction("ok?", { kind: "test" } as any),
232
- );
233
-
234
- expect(innerHandler).not.toHaveBeenCalled();
235
- });
236
- });
44
+ import { makeFlowAction } from "@finesoft/front";
45
+ await app.perform(makeFlowAction("/items/42", "modal"));
237
46
  ```
238
47
 
239
- ## When to use a custom action vs middleware
240
-
241
- | Concern | Custom action | Middleware (`beforeLoad`) |
242
- | ----------------------------------------------- | ------------- | ---------------------------- |
243
- | Confirmation before navigating to specific URLs | ✅ | ❌ (would run for every nav) |
244
- | Audit log on every navigation | ❌ | ✅ |
245
- | New mechanism for performing an operation | ✅ | ❌ |
246
- | Gate-keeping all navigation to admin routes | ❌ | ✅ |
247
-
248
- Custom actions are for **new kinds of operations.** Middleware is for **cross-cutting concerns on existing operations.**
48
+ The host runs navigation policies and page guards before invoking `onModal` once. The callback renders the modal with the application's native UI; background navigation and history remain unchanged. A denial supplies an error page and sanitized snapshot, never rejected page data. Internal redirects remain modal, and external redirects hand off without delivering a modal. Register `onModal` before using modal actions. SSR views cannot perform browser actions.