@finesoft/front 0.5.1 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/README.md +4 -4
  2. package/dist/Outlet.svelte +39 -0
  3. package/dist/Outlet.svelte.d.ts +7 -0
  4. package/dist/browser-CR5vhgXg.mjs +1317 -0
  5. package/dist/browser-kFMjlLGT.d.mts +262 -0
  6. package/dist/browser.d.mts +7 -2
  7. package/dist/browser.mjs +9 -1
  8. package/dist/controller-types-CgmJ6-le.d.mts +16 -0
  9. package/dist/cookies-BiRlUanX.d.mts +786 -0
  10. package/dist/fetch-policy-BHT8RtrL.mjs +82 -0
  11. package/dist/host-guard-DDWxLpFL.mjs +222 -0
  12. package/dist/http-B6CJqDyf.d.mts +46 -0
  13. package/dist/http-CaxrMD1A.d.mts +1 -0
  14. package/dist/http-D70PL72H.mjs +257 -0
  15. package/dist/http.d.mts +3 -0
  16. package/dist/http.mjs +2 -0
  17. package/dist/index-node.d.mts +15 -0
  18. package/dist/index-node.mjs +15 -0
  19. package/dist/index.d.mts +48 -697
  20. package/dist/index.mjs +44 -261
  21. package/dist/load-node.d.mts +5 -0
  22. package/dist/load-node.mjs +10 -0
  23. package/dist/load-portable.d.mts +5 -0
  24. package/dist/load-portable.mjs +8 -0
  25. package/dist/lru-map-BKoUAySU.mjs +50 -0
  26. package/dist/messages-CAt2QdGr.mjs +140 -0
  27. package/dist/native-contract-DuR25hYB.d.mts +14 -0
  28. package/dist/native-contract.d.mts +2 -0
  29. package/dist/native-contract.mjs +1 -0
  30. package/dist/node-D9hB4dsz.d.mts +35 -0
  31. package/dist/node.d.mts +2 -0
  32. package/dist/node.mjs +59 -0
  33. package/dist/path-CGFl2w7D.mjs +113 -0
  34. package/dist/path-CXT6xGPO.d.mts +261 -0
  35. package/dist/portable-CaxrMD1A.d.mts +1 -0
  36. package/dist/portable.d.mts +11 -0
  37. package/dist/portable.mjs +12 -0
  38. package/dist/proxy-2dSWO-Xw.d.mts +53 -0
  39. package/dist/proxy-z02VvGIj.mjs +7520 -0
  40. package/dist/public-types-BcJM-AYc.mjs +835 -0
  41. package/dist/react-DhwBRw01.d.mts +16 -0
  42. package/dist/react.d.mts +3 -0
  43. package/dist/react.mjs +29 -0
  44. package/dist/rolldown-runtime-B4iAMlE-.mjs +35 -0
  45. package/dist/secure-fetch-Xlht2jd7.d.mts +30 -0
  46. package/dist/server-controller-proxy-BkVuVWVD.d.mts +38 -0
  47. package/dist/session-DnB4ZC3x.d.mts +1279 -0
  48. package/dist/src-BQBfaaPO.mjs +3825 -0
  49. package/dist/src-Ftl_0rhu.mjs +28 -0
  50. package/dist/ssr-BLzYP4wU.d.mts +207 -0
  51. package/dist/ssr-Tn4YkuxM.mjs +357 -0
  52. package/dist/ssr.d.mts +3 -0
  53. package/dist/ssr.mjs +3 -0
  54. package/dist/svelte-Dr5to3SE.d.mts +16 -0
  55. package/dist/svelte.d.mts +3 -0
  56. package/dist/svelte.mjs +13 -0
  57. package/dist/typegen-C-WeJCtf.d.mts +12 -0
  58. package/dist/typegen-cli.d.mts +1 -0
  59. package/dist/typegen-cli.mjs +11 -0
  60. package/dist/typegen.d.mts +3 -0
  61. package/dist/typegen.mjs +2 -0
  62. package/dist/types-B1BT0N3t.mjs +402 -0
  63. package/dist/undici-CPfL25Hr.mjs +22262 -0
  64. package/dist/vite-Cj4SPA8D.d.mts +277 -0
  65. package/dist/vite.d.mts +4 -0
  66. package/dist/vite.mjs +2354 -0
  67. package/dist/vue-DGmzuKho.d.mts +32 -0
  68. package/dist/vue.d.mts +3 -0
  69. package/dist/vue.mjs +56 -0
  70. package/dist/web.d.mts +6 -0
  71. package/dist/web.mjs +8 -0
  72. package/dist/worker.d.mts +2 -0
  73. package/dist/worker.mjs +2 -0
  74. package/docs/01-getting-started.md +67 -199
  75. package/docs/02-routing-and-controllers.md +163 -241
  76. package/docs/03-middleware.md +10 -212
  77. package/docs/04-rendering-and-hydration.md +7 -332
  78. package/docs/05-i18n.md +7 -237
  79. package/docs/06-http-client.md +20 -263
  80. package/docs/07-di-container.md +23 -257
  81. package/docs/08-observability.md +6 -286
  82. package/docs/09-server-and-deployment.md +59 -219
  83. package/docs/10-features-platform-pwa.md +7 -231
  84. package/docs/11-navigation.md +143 -288
  85. package/docs/12-session-restoration.md +6 -214
  86. package/docs/README.md +7 -7
  87. package/docs/advanced/custom-action-handler.md +29 -229
  88. package/docs/advanced/custom-adapter.md +7 -259
  89. package/docs/advanced/custom-event-recorder.md +7 -312
  90. package/docs/advanced/inline-proxy-codegen.md +6 -185
  91. package/docs/advanced/multi-tenant-scopes.md +10 -323
  92. package/docs/engineering/ci-release-flow.md +35 -222
  93. package/docs/engineering/project-structure.md +34 -277
  94. package/docs/engineering/testing.md +8 -310
  95. package/docs/pitfalls/container-scope-leak.md +2 -214
  96. package/docs/pitfalls/i18n-bundle-size.md +6 -176
  97. package/docs/pitfalls/proxy-binary-payloads.md +8 -12
  98. package/docs/pitfalls/ssr-hydration-mismatch.md +9 -155
  99. package/docs/pitfalls/ssr-vs-csr-globals.md +9 -170
  100. package/docs/zh/01-getting-started.md +67 -199
  101. package/docs/zh/02-routing-and-controllers.md +166 -244
  102. package/docs/zh/03-middleware.md +10 -212
  103. package/docs/zh/04-rendering-and-hydration.md +7 -332
  104. package/docs/zh/05-i18n.md +7 -237
  105. package/docs/zh/06-http-client.md +20 -263
  106. package/docs/zh/07-di-container.md +23 -257
  107. package/docs/zh/08-observability.md +6 -283
  108. package/docs/zh/09-server-and-deployment.md +59 -219
  109. package/docs/zh/10-features-platform-pwa.md +7 -231
  110. package/docs/zh/11-navigation.md +130 -290
  111. package/docs/zh/12-session-restoration.md +6 -214
  112. package/docs/zh/README.md +4 -4
  113. package/docs/zh/advanced/custom-action-handler.md +29 -229
  114. package/docs/zh/advanced/custom-adapter.md +7 -259
  115. package/docs/zh/advanced/custom-event-recorder.md +7 -312
  116. package/docs/zh/advanced/inline-proxy-codegen.md +6 -185
  117. package/docs/zh/advanced/multi-tenant-scopes.md +10 -323
  118. package/docs/zh/engineering/ci-release-flow.md +35 -222
  119. package/docs/zh/engineering/project-structure.md +34 -277
  120. package/docs/zh/engineering/testing.md +8 -310
  121. package/docs/zh/pitfalls/container-scope-leak.md +2 -214
  122. package/docs/zh/pitfalls/i18n-bundle-size.md +6 -176
  123. package/docs/zh/pitfalls/proxy-binary-payloads.md +8 -12
  124. package/docs/zh/pitfalls/ssr-hydration-mismatch.md +9 -155
  125. package/docs/zh/pitfalls/ssr-vs-csr-globals.md +9 -170
  126. package/package.json +118 -20
  127. package/dist/browser-BHhVWXik.mjs +0 -2
  128. package/dist/browser-BV2BBXm7.d.mts +0 -2811
@@ -1,219 +1,11 @@
1
- # 12. 会话恢复
1
+ # 会话恢复与多实例
2
2
 
3
- 框架已经能恢复**首屏**:SSR prefetch 出来的 intent 结果经 `PrefetchedIntents` 注入 HTML,浏览器在首次导航时复用它们;结构化导航的当前树也会随 `history.state` 走前进 / 后退。
3
+ 使用 `createBrowserApp({ definition, target, history: "memory", persistenceKey: "first", session: {}, domRestore: true })` 创建嵌入应用。每个实例需要独立的 target 和稳定 persistenceKey;同一窗口只允许一个浏览器地址栏所有者。
4
4
 
5
- 但有一类状态以上手段**全都救不回**:用户**硬重载 / 标签崩溃 / 关掉再回来**时「当时在干什么」—— 他在哪一屏(或哪个栈深、哪个 tab、split 哪一列)、表单里打了一半的草稿、列表滚到哪里。内存里的 `history.state` map 整页重载即清空,`PrefetchedIntents` 只覆盖服务端渲染的那一屏。
5
+ 随后用原生 API 挂载 App,再等待 `app.ready`。会话读取及恢复在第一次原生提交确认之后开始,Outlet commit 不等待恢复。React 的持久化 provider 在 layout effect 注册;Vue/Svelte 在原生挂载阶段注册。`app.session.register(provider)` 返回反注册函数。provider capture/restore 直接读写原生业务状态,无需额外 NameStore。
6
6
 
7
- **会话恢复**填这个缺口:把一份带版本、JSON 安全的**会话快照**(导航位置 + 应用注册的状态切片 + 导航作用域的逐屏状态)序列化到可插拔 `Storage`,并在全新加载时重水化。框架**不含任何 UI** —— 它只恢复**状态**,应用据此自行重渲染。
7
+ 切换隐藏页面保留原生实例;pop 移除 entry 及其 scoped 草稿。`data-restore-root` 内明确标记的表单与滚动状态由 DOM 恢复层处理,范围限定在所属应用。多个实例不会读取彼此的输入。
8
8
 
9
- 它完全可选:从不向 `startBrowserApp` `session` 的应用**逐位等价**于原行为,毫无变化。
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) —— `createServer`、proxy、adapter、Vite 插件
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) —— 推荐布局、`bootstrap.ts` 拆分、单一来源
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
- IntentDispatcher (controller.execute() → Page;出错走 fallback())
60
+ RuntimeHandle (controller.execute() → Page;出错走 fallback())
61
61
  → afterLoad chain (PostLoadContext)
62
62
  → render (SSR: HTML + 序列化的 PrefetchedIntents;CSR: 空壳)
63
63
  ```
64
64
 
65
- 同一个 `bootstrap()` 同时在服务器和浏览器执行。SSR 把 prefetch 后的 intent 结果序列化进 HTML,浏览器再反序列化为 `PrefetchedIntents`,让首次客户端导航复用服务端结果而不重新发请求。
65
+ 服务器和浏览器使用同一应用声明。SSR 把 prefetch 后的 intent 结果序列化进 HTML,浏览器再反序列化为 `PrefetchedIntents`,让首次客户端导航复用服务端结果而不重新发请求。
66
66
 
67
67
  ## 约定
68
68
 
@@ -1,248 +1,48 @@
1
- # 高阶:自定义 Action handler
1
+ # 应用 Action
2
2
 
3
- 框架内置三种 action:`flow`(应用内导航)、`external-url`(整页浏览器跳转)、`compound`(按顺序执行的 action 元组)。对大多数应用够用。
3
+ WebSession 本身就是 ActionDispatcher 和导航状态所有者。原生页面、链接拦截、自定义处理器共用 `app.perform(action)`。Action 同时表达 URL 路由和 Stack、Tab、Split 结构化导航,不再有另一套导航命令对象。
4
4
 
5
- 本配方展示怎么加自己的 —— 适用于有一类操作需要横切处理(分析、确认、遥测)又不想污染每个调用点。
5
+ `FlowAction` 加载 URL。普通应用未显式声明导航树或 codec 时,离开的页面会卸载;结构化应用保留树中分支。同址 URL 刷新原条目,显式 `push` 则创建新条目,相同目标也有独立草稿。
6
6
 
7
- ## 用例:带确认的 action
7
+ `perform` 等待守卫、数据加载、提交及原生视图确认完成,返回导航快照。结构化动作被拒绝时返回带 `rejection` 的未提交快照。Compound 按顺序执行,遇到拒绝或失败即停止后续动作。第二个参数 `{ signal }` 可将取消传入页面加载。
8
8
 
9
- `"confirm"` action kind:dispatch `{ kind: "confirm", message, then }`,框架在 dispatch `then` 之前(`then` 本身也是个 action)显示确认对话框。
9
+ `app.onAction(kind, handler)` `app.removeAction(kind)` 在同一执行器上注册或显式替换处理器。业务数据操作继续调用 `app.runtime.execute`。ExternalUrlAction 使用 `noopener,noreferrer` 打开新窗口;`{ kind: "reuseEntry", entryId }` 恢复保留实例,无须额外携带 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
- 用户点 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
- ```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
- 或者更简单:在 `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
- ```ts
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
- 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
- 每个内层 action 顺序跑。一个 handler 抛错短路 compound 剩余 action —— 想要 best-effort 语义就 `try/catch` 包起来。
186
-
187
- ## 服务端考虑
25
+ ## 现有代码迁移
188
26
 
189
- Controller dispatch action 时 action handler 在 SSR 期间在服务端跑。常见模式:
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
- - **外部 URL**:服务端没法让用户导航 —— 大多数应用提前返回。框架内置 `external-url` handler 在 SSR 上正是这样做的。
192
- - **Confirm 类**:没用户能问。要么自动接受(用内层 action)要么自动拒绝(丢弃)。
193
- - **仅遥测**:两端工作一样。记录就行。
37
+ 旧导航命令与转发方法已删除,其余树操作使用同名 Action kind。不可变树构建纯函数继续保留。
194
38
 
195
- handler 依赖服务端没有的浏览器 API,用 `typeof window === "undefined"` 守卫。
39
+ ## 模态展示
196
40
 
197
- ## 测试
41
+ 创建浏览器应用时提供 `onModal(page, { app, snapshot })`,然后调用:
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
- ## 自定义 action vs 中间件 怎么选
240
-
241
- | 关注点 | 自定义 action | 中间件(`beforeLoad`) |
242
- | ------------------------------- | ------------- | ---------------------- |
243
- | 导航到特定 URL 前确认 | ✅ | ❌(每个导航都跑) |
244
- | 每次导航的审计日志 | ❌ | ✅ |
245
- | 引入新的操作机制 | ✅ | ❌ |
246
- | 把守所有导航到 admin 路由的访问 | ❌ | ✅ |
247
-
248
- 自定义 action 是**新种类的操作**。中间件是**已有操作的横切关注点**。
48
+ 宿主先运行导航策略和页面守卫,再调用一次 `onModal`,由业务原生 UI 呈现;背景导航与历史不变。拒绝只交付错误页和已清理快照,不交付被拒绝数据。内部重定向仍处于模态会话,外部重定向直接跳转且不交付模态。使用前须配置 `onModal`;SSR 视图不能执行浏览器动作。