@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.
- package/README.md +4 -4
- package/dist/Outlet.svelte +39 -0
- package/dist/Outlet.svelte.d.ts +7 -0
- package/dist/browser-DIU6Sxl3.mjs +1237 -0
- package/dist/browser-kFMjlLGT.d.mts +262 -0
- package/dist/browser.d.mts +7 -2
- package/dist/browser.mjs +9 -1
- package/dist/controller-types-CgmJ6-le.d.mts +16 -0
- package/dist/cookies-Bpf9VayB.d.mts +779 -0
- package/dist/fetch-policy-BHT8RtrL.mjs +82 -0
- package/dist/host-guard-DDWxLpFL.mjs +222 -0
- package/dist/http-B6CJqDyf.d.mts +46 -0
- package/dist/http-CaxrMD1A.d.mts +1 -0
- package/dist/http-D70PL72H.mjs +257 -0
- package/dist/http.d.mts +3 -0
- package/dist/http.mjs +2 -0
- package/dist/index-node.d.mts +15 -0
- package/dist/index-node.mjs +15 -0
- package/dist/index.d.mts +48 -697
- package/dist/index.mjs +44 -261
- package/dist/load-node.d.mts +5 -0
- package/dist/load-node.mjs +10 -0
- package/dist/load-portable.d.mts +5 -0
- package/dist/load-portable.mjs +8 -0
- package/dist/lru-map-BKoUAySU.mjs +50 -0
- package/dist/messages-CAt2QdGr.mjs +140 -0
- package/dist/native-contract-DuR25hYB.d.mts +14 -0
- package/dist/native-contract.d.mts +2 -0
- package/dist/native-contract.mjs +1 -0
- package/dist/node-D9hB4dsz.d.mts +35 -0
- package/dist/node.d.mts +2 -0
- package/dist/node.mjs +59 -0
- package/dist/path-CGFl2w7D.mjs +113 -0
- package/dist/path-CXT6xGPO.d.mts +261 -0
- package/dist/portable-CaxrMD1A.d.mts +1 -0
- package/dist/portable.d.mts +11 -0
- package/dist/portable.mjs +12 -0
- package/dist/proxy-1SphZ7x7.mjs +436 -0
- package/dist/proxy-2dSWO-Xw.d.mts +53 -0
- package/dist/public-types-BcJM-AYc.mjs +835 -0
- package/dist/react-DhwBRw01.d.mts +16 -0
- package/dist/react.d.mts +3 -0
- package/dist/react.mjs +29 -0
- package/dist/rolldown-runtime-B4iAMlE-.mjs +35 -0
- package/dist/secure-fetch-Xlht2jd7.d.mts +30 -0
- package/dist/server-controller-proxy-BkVuVWVD.d.mts +38 -0
- package/dist/session-DnB4ZC3x.d.mts +1279 -0
- package/dist/src-Ftl_0rhu.mjs +28 -0
- package/dist/src-qwx7Vw8g.mjs +3807 -0
- package/dist/ssr-BEUNDvbj.d.mts +210 -0
- package/dist/ssr-C8xnYXoY.mjs +357 -0
- package/dist/ssr.d.mts +3 -0
- package/dist/ssr.mjs +3 -0
- package/dist/svelte-Dr5to3SE.d.mts +16 -0
- package/dist/svelte.d.mts +3 -0
- package/dist/svelte.mjs +13 -0
- package/dist/typegen-C-WeJCtf.d.mts +12 -0
- package/dist/typegen-cli.d.mts +1 -0
- package/dist/typegen-cli.mjs +11 -0
- package/dist/typegen.d.mts +3 -0
- package/dist/typegen.mjs +2 -0
- package/dist/types-BuaZHRG7.mjs +402 -0
- package/dist/undici-CPfL25Hr.mjs +22262 -0
- package/dist/vite-Cj4SPA8D.d.mts +277 -0
- package/dist/vite.d.mts +4 -0
- package/dist/vite.mjs +2354 -0
- package/dist/vue-DGmzuKho.d.mts +32 -0
- package/dist/vue.d.mts +3 -0
- package/dist/vue.mjs +56 -0
- package/dist/web.d.mts +6 -0
- package/dist/web.mjs +8 -0
- package/dist/worker.d.mts +2 -0
- package/dist/worker.mjs +2 -0
- package/docs/01-getting-started.md +67 -199
- package/docs/02-routing-and-controllers.md +163 -241
- package/docs/03-middleware.md +10 -212
- package/docs/04-rendering-and-hydration.md +6 -333
- package/docs/05-i18n.md +7 -237
- package/docs/06-http-client.md +20 -263
- package/docs/07-di-container.md +23 -257
- package/docs/08-observability.md +6 -286
- package/docs/09-server-and-deployment.md +59 -219
- package/docs/10-features-platform-pwa.md +7 -231
- package/docs/11-navigation.md +143 -288
- package/docs/12-session-restoration.md +6 -214
- package/docs/README.md +7 -7
- package/docs/advanced/custom-action-handler.md +29 -229
- package/docs/advanced/custom-adapter.md +7 -259
- package/docs/advanced/custom-event-recorder.md +7 -312
- package/docs/advanced/inline-proxy-codegen.md +6 -185
- package/docs/advanced/multi-tenant-scopes.md +10 -323
- package/docs/engineering/ci-release-flow.md +35 -222
- package/docs/engineering/project-structure.md +34 -277
- package/docs/engineering/testing.md +8 -310
- package/docs/pitfalls/container-scope-leak.md +2 -214
- package/docs/pitfalls/i18n-bundle-size.md +6 -176
- package/docs/pitfalls/proxy-binary-payloads.md +8 -12
- package/docs/pitfalls/ssr-hydration-mismatch.md +4 -160
- package/docs/pitfalls/ssr-vs-csr-globals.md +9 -170
- package/docs/zh/01-getting-started.md +67 -199
- package/docs/zh/02-routing-and-controllers.md +166 -244
- package/docs/zh/03-middleware.md +10 -212
- package/docs/zh/04-rendering-and-hydration.md +6 -333
- package/docs/zh/05-i18n.md +7 -237
- package/docs/zh/06-http-client.md +20 -263
- package/docs/zh/07-di-container.md +23 -257
- package/docs/zh/08-observability.md +6 -283
- package/docs/zh/09-server-and-deployment.md +59 -219
- package/docs/zh/10-features-platform-pwa.md +7 -231
- package/docs/zh/11-navigation.md +130 -290
- package/docs/zh/12-session-restoration.md +6 -214
- package/docs/zh/README.md +4 -4
- package/docs/zh/advanced/custom-action-handler.md +29 -229
- package/docs/zh/advanced/custom-adapter.md +7 -259
- package/docs/zh/advanced/custom-event-recorder.md +7 -312
- package/docs/zh/advanced/inline-proxy-codegen.md +6 -185
- package/docs/zh/advanced/multi-tenant-scopes.md +10 -323
- package/docs/zh/engineering/ci-release-flow.md +35 -222
- package/docs/zh/engineering/project-structure.md +34 -277
- package/docs/zh/engineering/testing.md +8 -310
- package/docs/zh/pitfalls/container-scope-leak.md +2 -214
- package/docs/zh/pitfalls/i18n-bundle-size.md +6 -176
- package/docs/zh/pitfalls/proxy-binary-payloads.md +8 -12
- package/docs/zh/pitfalls/ssr-hydration-mismatch.md +4 -160
- package/docs/zh/pitfalls/ssr-vs-csr-globals.md +9 -170
- package/package.json +118 -20
- package/dist/browser-BHhVWXik.mjs +0 -2
- package/dist/browser-BV2BBXm7.d.mts +0 -2811
|
@@ -1,219 +1,11 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Session restoration and multiple instances
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) —
|
|
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,
|
|
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
|
-
|
|
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
|
-
→
|
|
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
|
|
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**
|
|
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
|
-
#
|
|
1
|
+
# Application actions
|
|
2
2
|
|
|
3
|
-
The
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
## Server-side considerations
|
|
25
|
+
## Migrating existing consumers
|
|
188
26
|
|
|
189
|
-
|
|
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
|
-
|
|
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
|
-
|
|
39
|
+
## Modal presentation
|
|
196
40
|
|
|
197
|
-
|
|
41
|
+
Pass `onModal(page, { app, snapshot })` to `createBrowserApp`, then call:
|
|
198
42
|
|
|
199
43
|
```ts
|
|
200
|
-
import {
|
|
201
|
-
|
|
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
|
-
|
|
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.
|