@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.
- package/README.md +4 -4
- package/dist/Outlet.svelte +39 -0
- package/dist/Outlet.svelte.d.ts +7 -0
- package/dist/browser-CR5vhgXg.mjs +1317 -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-BiRlUanX.d.mts +786 -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-2dSWO-Xw.d.mts +53 -0
- package/dist/proxy-z02VvGIj.mjs +7520 -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-BQBfaaPO.mjs +3825 -0
- package/dist/src-Ftl_0rhu.mjs +28 -0
- package/dist/ssr-BLzYP4wU.d.mts +207 -0
- package/dist/ssr-Tn4YkuxM.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-B1BT0N3t.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 +7 -332
- 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 +9 -155
- 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 +7 -332
- 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 +9 -155
- 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
|
+
# 会话恢复与多实例
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
使用 `createBrowserApp({ definition, target, history: "memory", persistenceKey: "first", session: {}, domRestore: true })` 创建嵌入应用。每个实例需要独立的 target 和稳定 persistenceKey;同一窗口只允许一个浏览器地址栏所有者。
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
随后用原生 API 挂载 App,再等待 `app.ready`。会话读取及恢复在第一次原生提交确认之后开始,Outlet 的 commit 不等待恢复。React 的持久化 provider 在 layout effect 注册;Vue/Svelte 在原生挂载阶段注册。`app.session.register(provider)` 返回反注册函数。provider 的 capture/restore 直接读写原生业务状态,无需额外 NameStore。
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
切换隐藏页面保留原生实例;pop 移除 entry 及其 scoped 草稿。`data-restore-root` 内明确标记的表单与滚动状态由 DOM 恢复层处理,范围限定在所属应用。多个实例不会读取彼此的输入。
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
`app.session.save()` / `clear()` 返回可检查的结果。相邻、尚未开始的隐式 save 合并;显式快照、load、restore、clear 构成顺序边界。最终 dispose 捕获当前状态并等待已登记存储操作;浏览器关闭仍不能保证异步存储完成。
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
一份快照捕获两层状态,一起序列化、跨重载一起恢复:
|
|
14
|
-
|
|
15
|
-
| 作用域 | 存放于 | 键 | 生命周期 | SwiftUI 对标 |
|
|
16
|
-
| ------------------ | -------- | -------------- | -------------------------------------- | --------------- |
|
|
17
|
-
| **全局切片** | `slices` | `provider.key` | 整个会话(主题、跨屏向导草稿…) | `@SceneStorage` |
|
|
18
|
-
| **导航作用域状态** | `scoped` | `entryKey` | 绑定到某个导航条目 —— 条目离树即被丢弃 | `@State` |
|
|
19
|
-
|
|
20
|
-
- **全局切片**是 app-wide 的。每个切片注册一个 `SessionStateProvider`;框架编排*何时*捕获与落盘,但从不解释内容 —— 它只搬运。
|
|
21
|
-
- **导航作用域状态**绑定到某个*导航条目*,对标 SwiftUI 视图 `@State` 的位置作用域生命周期(见下)。
|
|
22
|
-
|
|
23
|
-
## 全局切片:`SessionStateProvider`
|
|
24
|
-
|
|
25
|
-
应用为每个切片注册一个 provider。`capture()` 返回 JSON 安全的同步值;`restore(data)` 把它放回(应用自行 `setState` / 回填表单 / 滚动):
|
|
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
|
-
框架原样搬运值、从不窥探 —— 所以捕获什么由**你**决定。敏感字段就在 `capture()` 里自行排除;不注册的切片永不被捕获。
|
|
38
|
-
|
|
39
|
-
## 导航作用域状态:SwiftUI `@State` 生命周期
|
|
40
|
-
|
|
41
|
-
导航作用域状态是更有意思的一半。它按**条目身份**而非可见性建键,遵循与 SwiftUI 视图 `@State` 相同的位置作用域生命周期:
|
|
42
|
-
|
|
43
|
-
> `A` → push `B` → 返回(pop `B`)到 `A`:**`B` 的状态被丢弃,`A` 的状态仍在。**
|
|
44
|
-
|
|
45
|
-
机制:每个条目的状态袋按 `entryKey = intent + " " + stableStringify(params)` 存放 —— 与导航 controller 给目标用的身份同源,故**跨重载稳定**。每次导航提交后,框架把 scoped map **prune** 到树中**实际存在**的条目 —— 注意是*存在*,不是*可见*。条目已不在树中的键即被丢弃。
|
|
46
|
-
|
|
47
|
-
```ts
|
|
48
|
-
import { sessionEntryKey } from "@finesoft/front";
|
|
49
|
-
|
|
50
|
-
// 渲染某屏时,用该条目的键读写它的作用域袋:
|
|
51
|
-
const key = sessionEntryKey("post", { id: 7 });
|
|
52
|
-
store.scope.set(key, { scroll: 240, draft: "评论打了一半" });
|
|
53
|
-
const bag = store.scope.get(key); // -> { scroll: 240, draft: "..." } | undefined
|
|
54
|
-
```
|
|
55
|
-
|
|
56
|
-
逐步走一遍生命周期:
|
|
57
|
-
|
|
58
|
-
- **push `B`** → 树 `[A, B]`,present `{A, B}` → `A` 的状态**保留**(`A` 仍在栈中、只是不可见),`B` 拿到自己的作用域。
|
|
59
|
-
- **pop `B`** → 树 `[A]`,present `{A}` → **`B` 的作用域被 prune 丢弃**,`A` 的原样保留;返回 `A` 按保留态渲染。
|
|
60
|
-
- **切 TabView 的 tab** → 其它分支仍在树中 → 其状态保活(与 SwiftUI 让未激活 tab 保持挂载一致)。
|
|
61
|
-
- **跨重载** → `scoped` 随快照序列化;重载后每个仍在树的条目恢复各自作用域,之后 pop 照常丢弃。
|
|
62
|
-
|
|
63
|
-
`store.scope` 是 store 持有的 `NavigationScopedState` 实例 —— `get` / `set` / `delete` / `keys`,外加框架替你调用的 `prune(presentKeys)`。用高层 `startBrowserApp({ session })` 时无需直接持有 store:`mount` 回调(context)交给你的 `SessionHandle` 上的 `handle.scope` 就是同一个实例(restore 重建后仍指向最新),照样 `handle.scope.get(entryKey)` / `set(entryKey, data)`。
|
|
64
|
-
|
|
65
|
-
### 扁平 vs 结构化:保留语义**本质就是栈**
|
|
66
|
-
|
|
67
|
-
「把 `A` 留在 `B` 底下、pop 时丢 `B`、恢复 `A`」这套行为,按定义就是**栈语义** —— 所以它只在**结构化导航**里成立,那里栈 / 树能持有*存在但不可见*的条目。
|
|
68
|
-
|
|
69
|
-
**扁平单页没有栈**:`A → B` 是整页替换,故 `presentKeys()` 恒为单条目(当前 URL)。一离开某屏,其作用域即被 prune,浏览器**返回**是 fresh 重渲染。
|
|
70
|
-
|
|
71
|
-
两种模式都支持「当前屏作用域 + 跨重载恢复」。要「返回时保留上一屏」,就把它建成结构化栈 —— 用 push 而非 replace。这正是 `NavigationStack` 的*意义*,不是扁平模式的缺陷。
|
|
72
|
-
|
|
73
|
-
## 快照
|
|
74
|
-
|
|
75
|
-
`createSessionStore(options)` 返回 `SessionStore` 编排器。`capture()` 组装快照但不落盘;快照模型为:
|
|
76
|
-
|
|
77
|
-
```ts
|
|
78
|
-
interface SessionSnapshot {
|
|
79
|
-
readonly version: number;
|
|
80
|
-
readonly navigation?: SerializedNavigation | SessionUrlLocation; // 结构化树 | { url }
|
|
81
|
-
readonly slices: Readonly<Record<string, unknown>>; // provider.key -> capture()
|
|
82
|
-
readonly scoped: Readonly<Record<string, unknown>>; // entryKey -> 状态袋
|
|
83
|
-
readonly capturedAt: number; // epoch ms,用于 maxAgeMs 过期判断
|
|
84
|
-
}
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
`navigation` 用一个轻判别区分:`SerializedNavigation` 始终带 `kind`(leaf/stack/tabs/split),扁平的 `SessionUrlLocation` 带 `url`。用 `isUrlLocation(nav)` 区分二者。
|
|
88
|
-
|
|
89
|
-
store 暴露:
|
|
90
|
-
|
|
91
|
-
```ts
|
|
92
|
-
interface SessionStore {
|
|
93
|
-
register(provider: SessionStateProvider): () => void; // 返回反注册函数
|
|
94
|
-
readonly scope: NavigationScopedState;
|
|
95
|
-
capture(): SessionSnapshot; // 组装(nav + slices + scoped),无 I/O
|
|
96
|
-
persist(snapshot?: SessionSnapshot): void; // 省略则先 capture(),再落盘
|
|
97
|
-
load(): SessionSnapshot | undefined; // 读取 + 校验(version / maxAge / 结构)
|
|
98
|
-
restore(snapshot?: SessionSnapshot): void | Promise<void>; // 省略则先 load(),再应用
|
|
99
|
-
clear(): void; // 清除持久化快照
|
|
100
|
-
save(): void; // capture + persist —— 手动逃生口
|
|
101
|
-
}
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
`load()` 会丢弃版本不符、`capturedAt` 超过 `maxAgeMs`、或结构畸形的快照 —— 返回 `undefined`,绝不向应用抛错。`capture()` / `restore()` 抛错的 provider 被隔离:跳过其切片、错误走 `onError`,快照其余部分照常存活。
|
|
105
|
-
|
|
106
|
-
## 持久化:默认 `sessionStorage`,可替换
|
|
107
|
-
|
|
108
|
-
快照经稳定 stringify 编码,作为一条 `storage.set(key, ...)` 写入。`Storage` 是 core 既有的依赖接口,所以 durability 由**你**决定:
|
|
109
|
-
|
|
110
|
-
```ts
|
|
111
|
-
import { createWebStorage } from "@finesoft/front";
|
|
112
|
-
|
|
113
|
-
createWebStorage("session"); // sessionStorage —— 标签级,关闭即清(默认)
|
|
114
|
-
createWebStorage("local"); // localStorage —— 跨标签、跨重启持久
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
`createWebStorage` 把 `get`/`set`/`delete` 映射到 `getItem`/`setItem`/`removeItem`,写入时吞掉配额错(会话恢复是尽力而为,绝不打断导航),选定的 Web Storage 不可用时(如隐私模式 `SecurityError`)降级为安全 no-op。
|
|
118
|
-
|
|
119
|
-
因为它就是 `Storage` 接口,你可以塞**任意**实现 —— 测试用内存版,或服务端同步的 `Storage` 实现跨设备恢复。框架 v1 不内建服务端端点,但接缝是开放的。
|
|
120
|
-
|
|
121
|
-
## 接入浏览器
|
|
122
|
-
|
|
123
|
-
向 `startBrowserApp` 传可选的 `session`。存在时,框架构建 `SessionStore`、注册你的 providers、装配 `SessionBridge`(导航变更自动捕获 + `pagehide`/`visibilitychange`),在首次导航后跑 boot 恢复,并把 `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 缺省为 createWebStorage("session")
|
|
137
|
-
maxAgeMs: 1000 * 60 * 60 * 24, // 丢弃超过一天的快照(可选)
|
|
138
|
-
},
|
|
139
|
-
mount(target, { session, app }) {
|
|
140
|
-
// session: SessionHandle(save/clear/scope/…);app:统一的 nav+session 句柄。
|
|
141
|
-
// 自动捕获/恢复已在跑;用 session.save() / session.clear() 作逃生口。
|
|
142
|
-
// ... 把 UI 挂载到 target,将 app(或 session)传给组件 ...
|
|
143
|
-
return () => undefined;
|
|
144
|
-
},
|
|
145
|
-
});
|
|
146
|
-
```
|
|
147
|
-
|
|
148
|
-
`session` **缺省**时,以上整段不运行,原有 `startBrowserApp` 路径逐位不变。
|
|
149
|
-
|
|
150
|
-
### 扁平 vs 结构化接线(自动)
|
|
151
|
-
|
|
152
|
-
`startBrowserApp` 替你挑选导航适配器:
|
|
153
|
-
|
|
154
|
-
- **有** `navigation` 配置 → 结构化 `createNavigationSessionAdapter(controller)`:序列化整棵树,恢复时 `hydrate` 回去。自动捕获由导航 handle 的 `subscribe` 驱动。
|
|
155
|
-
- **无** `navigation`(扁平单页)→ 接 `framework.perform(makeFlowAction(url))` 的 `createUrlSessionAdapter`:捕获 `{ url }`,恢复时导航过去。
|
|
156
|
-
|
|
157
|
-
只有自己装配 store 时(如在服务端、或测试里)才需要直接选适配器。
|
|
158
|
-
|
|
159
|
-
## 句柄:手动 save / clear / dispose
|
|
160
|
-
|
|
161
|
-
`SessionHandle`(通过 mount context 交付)给你逃生口 —— 自动捕获已在跑,但你可强制落盘、清快照、或整体拆除。统一的 `app` 句柄把导航命令与 session 的 `save`/`clear`/`scope` 合并,组件拿一个对象即可,免自己拼 controller:
|
|
162
|
-
|
|
163
|
-
```ts
|
|
164
|
-
interface SessionHandle {
|
|
165
|
-
restore(currentUrl: string): void | Promise<void>; // boot 恢复(已替你调过)
|
|
166
|
-
save(): void; // 立即强制落盘
|
|
167
|
-
clear(): void; // 丢弃持久化快照(如登出时)
|
|
168
|
-
dispose(): void; // 反订阅导航 + 解绑 pagehide/visibilitychange + 清定时器
|
|
169
|
-
}
|
|
170
|
-
```
|
|
171
|
-
|
|
172
|
-
登出时调 `handle.clear()`,下个用户就不会继承陈旧会话;自己拆除应用实例时调 `handle.dispose()`。
|
|
173
|
-
|
|
174
|
-
### 何时捕获?
|
|
175
|
-
|
|
176
|
-
你很少需要调 `save()` —— 捕获是自动的:
|
|
177
|
-
|
|
178
|
-
- **导航变更时**:bridge **先**把 scoped map prune 到 `adapter.presentKeys()`(这正是「pop `B` 丢掉 `B` 状态」的落点),再**防抖**落盘(默认 `SESSION_DEFAULT_DEBOUNCE_MS` = 500 ms,合并连续导航)。用 `session.debounceMs` 调。
|
|
179
|
-
- **`pagehide` 与 `visibilitychange`(hidden)时**:**立即**落盘并取消挂起的防抖 —— 比 `beforeunload` 在移动端更可靠(标签切后台 / 被回收前能抓到末态)。
|
|
180
|
-
|
|
181
|
-
## 深链策略:`shouldRestore`
|
|
182
|
-
|
|
183
|
-
boot 时 bridge 读快照,**仅当** `shouldRestore(snapshot, currentUrl)` 通过才应用 —— 整份 `nav + slices` 恢复共用一个布尔门。默认的 `defaultShouldRestore` 遵循**显式深链优先于陈旧会话**:
|
|
184
|
-
|
|
185
|
-
| 快照 `navigation` | 恢复当且仅当… |
|
|
186
|
-
| ------------------------------------ | --------------------------------------------------------------- |
|
|
187
|
-
| **扁平**(`SessionUrlLocation`) | `currentUrl === snapshot.navigation.url` **或**当前路径为根 `/` |
|
|
188
|
-
| **结构化**(`SerializedNavigation`) | 当前路径为根 `/` |
|
|
189
|
-
| **无**(仅切片) | 总恢复(与 URL 无关) |
|
|
190
|
-
|
|
191
|
-
于是重载同页(或全新进入 `/`)会恢复;打开不同深链 `/x` 则**不会**被旧会话覆盖。「根」判定为路径 `=== "/"`(剥离 query/hash)。带 base path 的应用应覆盖该门:
|
|
192
|
-
|
|
193
|
-
```ts
|
|
194
|
-
session: {
|
|
195
|
-
providers: [themeSlice],
|
|
196
|
-
shouldRestore: (snapshot, currentUrl) => currentUrl.startsWith("/app/"),
|
|
197
|
-
}
|
|
198
|
-
```
|
|
199
|
-
|
|
200
|
-
恢复到与 SSR'd URL 不同的态会产生一次客户端跳变(SSR 渲染 URL 那屏,客户端再恢复)。该时机经 bridge 暴露给你掌控;纯 CSR 应用可在首次绘制前恢复、完全避免它。
|
|
201
|
-
|
|
202
|
-
## 哪些**不**被捕获
|
|
203
|
-
|
|
204
|
-
- **你没注册的 DOM。** 框架从不扫描 DOM。状态切片就是你的 provider `capture()` 出来的那些 —— 仅此而已。
|
|
205
|
-
- **不注册任何 provider 时的一切。** 只注册导航(或什么都不注册)时,捕获实质为零 —— 隐私默认。
|
|
206
|
-
- **你排除的敏感字段。** `capture()` 是你的过滤器;token、PII 之类在此剥掉。
|
|
207
|
-
- **陈旧 / 过期 / 畸形的快照。** `load()` 返回 `undefined`,而非为恢复坏态崩掉应用。
|
|
208
|
-
|
|
209
|
-
## 向后兼容
|
|
210
|
-
|
|
211
|
-
- 不向 `startBrowserApp` 传 `session` 的应用走**原路径**、零行为变化 —— 整个特性被那一个字段门控。
|
|
212
|
-
- 会话恢复对服务端无任何要求。提供自己的 `Storage` 即可实现服务端同步快照,但框架不内建任何东西。
|
|
213
|
-
- 框架恢复**状态**、绝不恢复 UI。你的 `Page` 模型与渲染方式原封不动。
|
|
214
|
-
|
|
215
|
-
## 下一步
|
|
216
|
-
|
|
217
|
-
- [导航](./11-navigation.md) —— 其条目为逐屏状态划定作用域的结构化树
|
|
218
|
-
- [渲染与 Hydration](./04-rendering-and-hydration.md) —— 首屏如何已经通过 prefetch 结果被恢复
|
|
219
|
-
- [DI 容器](./07-di-container.md) —— 会话恢复落盘所经的 `Storage` 依赖
|
|
11
|
+
清理顺序是 `try { await app.dispose(); } finally { nativeRoot.unmount(); }`,Svelte 使用其 `unmount` 函数。另一个实例仍可通过 `other.perform({ kind: "flow", url: "/" })` 工作。协议 v2 使用导航树,旧 URL-only 快照会被判为不兼容;业务切片有独立版本和迁移契约。
|
package/docs/zh/README.md
CHANGED
|
@@ -20,14 +20,14 @@
|
|
|
20
20
|
6. [HTTP 客户端](./06-http-client.md) —— `HttpClient` 子类化、拦截器、`HttpError`
|
|
21
21
|
7. [DI 容器](./07-di-container.md) —— 注册、scope、`DEP_KEYS`、dispose
|
|
22
22
|
8. [可观测性](./08-observability.md) —— `Logger`、`EventRecorder`、Impression 追踪、`ReportCallback`
|
|
23
|
-
9. [服务器与部署](./09-server-and-deployment.md) ——
|
|
23
|
+
9. [服务器与部署](./09-server-and-deployment.md) —— HTTP / Node / Worker、proxy、adapter、Vite 插件
|
|
24
24
|
10. [Feature flags、平台、PWA](./10-features-platform-pwa.md) —— 特性开关、平台检测、PWA 模式
|
|
25
25
|
|
|
26
26
|
### 已经在维护项目的工程师 —— 直接看实践
|
|
27
27
|
|
|
28
28
|
横切关注点和约定。先理解基础后再读。
|
|
29
29
|
|
|
30
|
-
- [项目结构](./engineering/project-structure.md) —— 推荐布局、`
|
|
30
|
+
- [项目结构](./engineering/project-structure.md) —— 推荐布局、`app-definition.ts` 拆分、单一来源
|
|
31
31
|
- [测试](./engineering/testing.md) —— Controller、中间件、scoped DI、mock 框架
|
|
32
32
|
- [CI 与发布流程](./engineering/ci-release-flow.md) —— changesets、内联发布 workflow、版本对账
|
|
33
33
|
|
|
@@ -57,12 +57,12 @@
|
|
|
57
57
|
```
|
|
58
58
|
URL/Action → Router.resolve()
|
|
59
59
|
→ beforeLoad chain (NavigationContext: redirect/rewrite/deny/next)
|
|
60
|
-
→
|
|
60
|
+
→ RuntimeHandle (controller.execute() → Page;出错走 fallback())
|
|
61
61
|
→ afterLoad chain (PostLoadContext)
|
|
62
62
|
→ render (SSR: HTML + 序列化的 PrefetchedIntents;CSR: 空壳)
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
服务器和浏览器使用同一应用声明。SSR 把 prefetch 后的 intent 结果序列化进 HTML,浏览器再反序列化为 `PrefetchedIntents`,让首次客户端导航复用服务端结果而不重新发请求。
|
|
66
66
|
|
|
67
67
|
## 约定
|
|
68
68
|
|
|
@@ -1,248 +1,48 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 应用 Action
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
WebSession 本身就是 ActionDispatcher 和导航状态所有者。原生页面、链接拦截、自定义处理器共用 `app.perform(action)`。Action 同时表达 URL 路由和 Stack、Tab、Split 结构化导航,不再有另一套导航命令对象。
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
`FlowAction` 加载 URL。普通应用未显式声明导航树或 codec 时,离开的页面会卸载;结构化应用保留树中分支。同址 URL 刷新原条目,显式 `push` 则创建新条目,相同目标也有独立草稿。
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
`perform` 等待守卫、数据加载、提交及原生视图确认完成,返回导航快照。结构化动作被拒绝时返回带 `rejection` 的未提交快照。Compound 按顺序执行,遇到拒绝或失败即停止后续动作。第二个参数 `{ signal }` 可将取消传入页面加载。
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
`app.onAction(kind, handler)` 与 `app.removeAction(kind)` 在同一执行器上注册或显式替换处理器。业务数据操作继续调用 `app.runtime.execute`。ExternalUrlAction 使用 `noopener,noreferrer` 打开新窗口;`{ kind: "reuseEntry", entryId }` 恢复保留实例,无须额外携带 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
|
-
用户点 Cancel → 不导航。点 OK → 内层 flow action 触发。
|
|
20
|
-
|
|
21
|
-
## 步骤 1:定义 action 类型
|
|
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
|
-
形状你定 —— `kind` 只要在已注册 handler 中唯一即可。
|
|
43
|
-
|
|
44
|
-
## 步骤 2:扩展 `Action` 类型 union
|
|
45
|
-
|
|
46
|
-
TypeScript 不会自动扩展框架的 `Action` 类型。声明模块增强:
|
|
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
|
-
框架暴露 `ActionRegistry` 的话(大多数可插拔框架会),TypeScript 就知道你的新 kind。没暴露的话,注册时 cast:
|
|
58
|
-
|
|
59
|
-
```ts
|
|
60
|
-
framework.actionDispatcher.register("confirm" as any, handleConfirm as any);
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
运行时不在乎 —— `kind` dispatch 时就是普通字符串。
|
|
64
|
-
|
|
65
|
-
## 步骤 3:写 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:没法弹确认 —— 直接 dispatch 内层 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
|
-
要点:
|
|
88
|
-
|
|
89
|
-
- handler 服务端和客户端都跑。服务端没有 `window` —— 决定「没 UI」对你的 action 意味着什么。
|
|
90
|
-
- 递归 dispatch(`actionDispatcher.dispatch(action.then)`)走普通管线,包含任何其他自定义 handler。
|
|
91
|
-
- 框架已经用递归深度限制(默认 4)保护 compound action。你的 handler 通过 dispatch 到达,继承了这个限制。
|
|
92
|
-
|
|
93
|
-
## 步骤 4:应用启动时注册
|
|
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
|
-
SSR 端镜像一份:
|
|
11
|
+
处理器接收 `(action, invocation)`。委托其他动作时,将同一个 invocation 传给 `app.perform(nextAction, invocation)`,使整个执行序列共用取消边界。新的 URL 导航取消旧动作组;结构化动作仍顺序执行,并阻止尚未完成解析的旧 URL 覆盖它。模态和外链动作不替换背景导航。
|
|
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
|
-
或者更简单:在 `bootstrap()` 内注册,两端都自动拿到。
|
|
126
|
-
|
|
127
|
-
## 步骤 5:使用
|
|
128
|
-
|
|
129
|
-
```ts
|
|
130
|
-
// 在 view 组件里
|
|
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
|
-
## 替换已有 handler
|
|
141
|
-
|
|
142
|
-
每个 `kind` 只能注册一次。dispatcher 对重复注册打 warning 并跳过:
|
|
143
|
-
|
|
144
|
-
```ts
|
|
145
|
-
framework.actionDispatcher.register("flow", myFlowHandler);
|
|
146
|
-
// [ActionDispatcher] kind="flow" already registered, skipping
|
|
147
|
-
```
|
|
148
|
-
|
|
149
|
-
要替换先 unregister:
|
|
150
|
-
|
|
151
|
-
```ts
|
|
152
|
-
framework.actionDispatcher.removeAction("flow");
|
|
153
|
-
framework.actionDispatcher.register("flow", myFlowHandler);
|
|
154
|
-
```
|
|
155
|
-
|
|
156
|
-
适用于想用日志或分析包默认 flow handler:
|
|
13
|
+
浏览器导航与守卫重定向接受 HTTP(S),ExternalUrlAction 还接受 `mailto:`、`tel:`。无效 URL 和可执行或不支持的协议在浏览器跳转前拒绝;需要自定义协议时可以显式替换处理器。
|
|
157
14
|
|
|
158
|
-
|
|
159
|
-
import { registerFlowActionHandler, type FlowActionDependencies } from "@finesoft/front";
|
|
160
|
-
|
|
161
|
-
const baseHandler = framework.actionDispatcher.getHandler("flow"); // 假设暴露
|
|
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
|
-
实践中,导航的横切关注点优先用中间件(`beforeLoad`)—— 替换 flow handler 太侵入。
|
|
170
|
-
|
|
171
|
-
## 自定义 kind 的 compound action
|
|
172
|
-
|
|
173
|
-
`CompoundAction` 配任何已注册 kind 都行:
|
|
15
|
+
## 两种导航
|
|
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
|
-
## 服务端考虑
|
|
25
|
+
## 现有代码迁移
|
|
188
26
|
|
|
189
|
-
|
|
27
|
+
| 旧 API | 替换方式 |
|
|
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)`,保留原有结构化字段 |
|
|
32
|
+
| 首次 `controller.resolve()` | `controller.start()` |
|
|
33
|
+
| `app.actionDispatcher.onAction/removeAction` | `app.onAction/removeAction` |
|
|
34
|
+
| `FlowAction.entryId` | `{ kind: "reuseEntry", entryId }` |
|
|
35
|
+
| `NavigationHandle`、`SessionHandle`、`SessionAccess` | History 清理使用 `NavigationBridge`;持久化使用 `SessionStore`;浏览器启动恢复使用 `BrowserSession.restoreFromUrl` |
|
|
190
36
|
|
|
191
|
-
|
|
192
|
-
- **Confirm 类**:没用户能问。要么自动接受(用内层 action)要么自动拒绝(丢弃)。
|
|
193
|
-
- **仅遥测**:两端工作一样。记录就行。
|
|
37
|
+
旧导航命令与转发方法已删除,其余树操作使用同名 Action kind。不可变树构建纯函数继续保留。
|
|
194
38
|
|
|
195
|
-
|
|
39
|
+
## 模态展示
|
|
196
40
|
|
|
197
|
-
|
|
41
|
+
创建浏览器应用时提供 `onModal(page, { app, snapshot })`,然后调用:
|
|
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
|
-
| 关注点 | 自定义 action | 中间件(`beforeLoad`) |
|
|
242
|
-
| ------------------------------- | ------------- | ---------------------- |
|
|
243
|
-
| 导航到特定 URL 前确认 | ✅ | ❌(每个导航都跑) |
|
|
244
|
-
| 每次导航的审计日志 | ❌ | ✅ |
|
|
245
|
-
| 引入新的操作机制 | ✅ | ❌ |
|
|
246
|
-
| 把守所有导航到 admin 路由的访问 | ❌ | ✅ |
|
|
247
|
-
|
|
248
|
-
自定义 action 是**新种类的操作**。中间件是**已有操作的横切关注点**。
|
|
48
|
+
宿主先运行导航策略和页面守卫,再调用一次 `onModal`,由业务原生 UI 呈现;背景导航与历史不变。拒绝只交付错误页和已清理快照,不交付被拒绝数据。内部重定向仍处于模态会话,外部重定向直接跳转且不交付模态。使用前须配置 `onModal`;SSR 视图不能执行浏览器动作。
|