@finesoft/front 0.3.0 → 0.4.1

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.
@@ -1,6 +1,6 @@
1
1
  # 4. 渲染与 Hydration
2
2
 
3
- 页面从 Controller 输出到字节流再回到活着的浏览器应用要走的路。本章覆盖 SSR、CSR、prerender,以及把两端绑在一起的 `PrefetchedIntents` 机制。
3
+ 页面从 Controller 输出到字节流再回到活着的浏览器应用要走的路。本章覆盖 SSR、CSR、prerender,它们正交组合的第二根轴 —— **应用架构**(扁平单页 vs 结构化导航 + islands)—— 以及把两端绑在一起的 `PrefetchedIntents` 机制。
4
4
 
5
5
  ## 三种模式并排比
6
6
 
@@ -15,6 +15,25 @@
15
15
 
16
16
  模式是**按路由**配置,自由混合。
17
17
 
18
+ ## 两根轴:渲染模式 × 应用架构
19
+
20
+ 渲染模式是一根轴。**应用架构**是正交的第二根轴:
21
+
22
+ - **扁平单页** —— 服务端 `createSSRRender`,客户端单次挂载、每次导航重渲。一个 root、一个可见页。(见下方 [SSR 管线](#ssr-管线)。)
23
+ - **结构化导航 + islands** —— 服务端 `createSSRNavigationRender`,客户端按目标挂为 _islands_:独立 root,切 tab / 栈时保活不销毁。(见 [导航](./11-navigation.md) 与下方 [Islands SSR](#islands-ssr结构化架构方案-c)。)
24
+
25
+ 两轴组合成矩阵 —— 渲染模式决定 HTML _何时/何地_ 产出,架构决定应用 _怎么组织_:
26
+
27
+ | | 扁平单页 | 结构化导航 + islands(方案 C) |
28
+ | ------------- | ----------------------- | --------------------------------- |
29
+ | **ssr** | ✅ `svelte-minimal` | ✅ `vue-minimal`、`react-minimal` |
30
+ | **csr** | ◐ 空壳 → 单 client root | ◐ 空壳 → islands 客户端挂 |
31
+ | **prerender** | ◐ 缓存扁平 SSR | ◐ 缓存方案 C SSR |
32
+
33
+ ✅ 有 starter 模板实证 · ◐ 设计上成立,暂无 starter 模板。
34
+
35
+ **islands 是 SSR 还是 CSR,是模式的结果、不是独立选项**:`ssr`/`prerender` 下框架服务端渲出每个可见 island、客户端 _收养并水合_;`csr` 下无服务端 HTML,每个 island 都在客户端新挂。每种模式的子维度仍叠加其上 —— CSR 有两个触发点([见下](#csr客户端渲染)),prerender 有构建期静态与运行时 ISR 两种形态([见下](#prerender静态--isr))。会话恢复 + DOM 恢复是再一层正交(客户端、post-hydration),可叠加在任意格上 —— 见 [会话恢复](./12-session-restoration.md)。
36
+
18
37
  ## SSR 管线
19
38
 
20
39
  ```
@@ -83,6 +102,57 @@ Vite 插件和 adapter 替你调用 `render(url, options)`。你返回 `{ html,
83
102
  - 根据 `deny()` / `redirect()` / `rewrite()` 结果设置 HTTP status
84
103
  - `afterLoad` 发出 rewrite 信号时加 `Content-Location` 头
85
104
 
105
+ ## Islands SSR(结构化架构,"方案 C")
106
+
107
+ 结构化架构把 **chrome**(tab bar、header —— 持久外框)和 **island 内容**(当前活动页)渲为**独立的水合 root**,作为挂载节点下的 sibling:
108
+
109
+ ```html
110
+ <div id="app">
111
+ <div data-fs-chrome><!-- chrome 渲在这 --></div>
112
+ <main data-fs-outlet><!-- 每个可见 island 渲在这 --></main>
113
+ </div>
114
+ ```
115
+
116
+ **服务端** —— `renderApp` 渲 chrome;`renderIslandsHtml(snapshot, renderEntry)` 把每个可见目标渲进 outlet,带上共享标记(`data-fs-entry` / `data-fs-intent` / `data-fs-key`)供客户端匹配:
117
+
118
+ ```ts
119
+ // src/ssr.ts —— 结构化入口(createSSRNavigationRender)
120
+ async renderApp(page, _framework, snapshot) {
121
+ const chromeHtml = await renderToString(createSSRApp(App, { snapshot }));
122
+ const islandsHtml = await renderIslandsHtml(snapshot, (entry) =>
123
+ renderToString(createSSRApp(VIEWS[entry.intent], { page: entry.page })),
124
+ );
125
+ return {
126
+ html: `<div data-fs-chrome>${chromeHtml}</div><main data-fs-outlet>${islandsHtml}</main>`,
127
+ head: `<title>${page.title}</title>`,
128
+ css: "",
129
+ };
130
+ }
131
+ ```
132
+
133
+ **客户端** —— `resolveIslandsShell(target)` 找到(或创建)chrome/outlet sibling,并报告 chrome 是否服务端渲过(`hydrate`)。island 编排器按 `data-fs-key` 收养每个 SSR'd 容器,调你的 `mountEntry(entry, container)` 时置 `entry.hydrate = true`,让你水合已有 DOM 而非新建:
134
+
135
+ ```ts
136
+ // src/main.ts
137
+ const mountEntry = (entry, container) => {
138
+ const factory = entry.hydrate ? createSSRApp : createApp; // 水合 SSR'd vs 新建(客户端导航)
139
+ const app = factory(VIEWS[entry.intent], { page: entry.page, controller: ctx.app });
140
+ app.mount(container);
141
+ return { unmount: () => app.unmount() };
142
+ };
143
+
144
+ startBrowserApp({
145
+ bootstrap,
146
+ mount,
147
+ callbacks,
148
+ navigation: { ...navigation.toBrowserConfig(), mountEntry },
149
+ });
150
+ ```
151
+
152
+ > **同步挂载契约。** `mountEntry` 返回后,island 的 DOM 必须已就绪:框架在下一帧回填 `data-restore-root` 字段(见 [会话恢复](./12-session-restoration.md))。Vue/Svelte 的 `.mount()` 同步满足。**React** 异步提交 DOM,故须把**客户端新挂**路径用 `flushSync(() => root.render(view))` 包住 —— 仅客户端新挂的 island 需要(SSR'd island 的 DOM 已来自服务端)。见 `templates/react-minimal/src/main.tsx`。
153
+
154
+ 完整示例:`templates/vue-minimal` 与 `templates/react-minimal`(均为 `ssr` + 结构化导航 + islands + 会话恢复)。
155
+
86
156
  ## CSR(客户端渲染)
87
157
 
88
158
  `renderMode: "csr"` 的路由,服务端返回最小空壳:
@@ -123,21 +193,18 @@ adapter 直接服务这些静态文件。请求时不跑 Controller。
123
193
 
124
194
  ### 增量静态再生成(ISR)
125
195
 
126
- 打包的服务器(`createServer`)和预览服务器(`vp preview`)支持按需缓存的再生成。通过 `finesoftFrontViteConfig` 配置:
196
+ 打包的服务器(`createServer`)和预览服务器(`vp preview`)会在运行时缓存 `prerender` 路由:路由在**首次**请求时渲染,HTML 存入内存 LRU(`ISR_CACHE_MAX = 1000` 条,按最近最少使用驱逐)。后续请求直接吐缓存、不再跑 controller。
197
+
198
+ 把路由标 `prerender`:路由级(`renderMode: "prerender"`)或经 Vite 插件按 glob 配置(配置级优先于路由级):
127
199
 
128
200
  ```ts
129
201
  finesoftFrontViteConfig({
130
202
  ssr: { entry: "src/ssr.ts" },
131
- isr: {
132
- // 哪些路由按需再生成
133
- routes: ["/blog/*"],
134
- // 缓存 TTL(秒)
135
- ttl: 300,
136
- },
203
+ renderModes: { "/blog/*": "prerender" },
137
204
  });
138
205
  ```
139
206
 
140
- 过期后的首个请求触发新一轮渲染;并发请求拿到陈旧版本直到新版生成完成。详见 [服务器与部署](./09-server-and-deployment.md#isr)。
207
+ 运行时缓存**无 TTL、无后台再生成** —— 条目存活到被 LRU 驱逐或进程重启为止。基于时间的 stale-while-revalidate 由平台 adapter 委托给 CDN(Netlify 发真正的 `stale-while-revalidate` 头;Cloudflare 发普通 `max-age`;node/Vercel 不发)。完整说明见 [服务器与部署](./09-server-and-deployment.md#isr增量静态再生成)。
141
208
 
142
209
  ## `PrefetchedIntents` —— SSR → CSR 的桥梁
143
210
 
@@ -22,7 +22,7 @@ export default defineConfig({
22
22
  i18n: { messagesDir: "src/locales" },
23
23
  proxies: [{ prefix: "/api", target: "https://upstream.example" }],
24
24
  adapter: "auto",
25
- isr: { routes: ["/blog/*"], ttl: 300 },
25
+ renderModes: { "/blog/*": "prerender" },
26
26
  }),
27
27
  ],
28
28
  });
@@ -30,13 +30,13 @@ export default defineConfig({
30
30
 
31
31
  ### 选项
32
32
 
33
- | 选项 | 类型 | 说明 |
34
- | ------------------ | ------------------------- | ------------------------------------------- |
35
- | `ssr.entry` | `string` | SSR 入口路径(默认 `src/ssr.ts`)。 |
36
- | `i18n.messagesDir` | `string` | 含 `{locale}.json` 文件的目录(默认关闭)。 |
37
- | `proxies` | `ProxyRouteConfig[]` | 声明式 API 转发。详见下文。 |
38
- | `adapter` | `"auto" \| "node" \| ...` | 目标平台。`"auto"` 从环境变量检测。 |
39
- | `isr` | `{ routes, ttl }` | Prerendered 路由的 ISR|
33
+ | 选项 | 类型 | 说明 |
34
+ | ------------------ | ---------------------------- | ------------------------------------------------------------ |
35
+ | `ssr.entry` | `string` | SSR 入口路径(默认 `src/ssr.ts`)。 |
36
+ | `i18n.messagesDir` | `string` | 含 `{locale}.json` 文件的目录(默认关闭)。 |
37
+ | `proxies` | `ProxyRouteConfig[]` | 声明式 API 转发。详见下文。 |
38
+ | `adapter` | `"auto" \| "node" \| ...` | 目标平台。`"auto"` 从环境变量检测。 |
39
+ | `renderModes` | `Record<string, RenderMode>` | 按路由覆盖渲染模式(glob 键);`"prerender"` 启用 ISR 缓存。 |
40
40
 
41
41
  ### 它做什么
42
42
 
@@ -55,21 +55,20 @@ Build:
55
55
 
56
56
  ## `createServer` —— 独立 Hono 服务器
57
57
 
58
- Node 部署和测试时,框架导出一个函数给你一个开箱即用的 Hono app
58
+ Node 部署和测试时,框架导出一个 async 工厂。它加载 `.env`、检测运行时、构建 Hono app、注册 proxies + 你的 `setup` 路由、挂 SSR catch-all,并**启动监听**(端口取自配置或 `PORT`,默认 `3000`)—— 然后返回 `{ app, vite, runtime }`:
59
59
 
60
60
  ```ts
61
61
  import { createServer } from "@finesoft/front";
62
62
 
63
- const app = createServer({
64
- ssrEntry: "./dist/server/ssr.js",
63
+ const { app } = await createServer({
64
+ ssr: { ssrProductionModule: "./dist/server/ssr.js" }, // dev 用 ssrEntryPath
65
65
  proxies: [{ prefix: "/api", target: "https://upstream.example" }],
66
- staticDir: "./dist/client",
67
- isr: { routes: ["/blog/*"], ttl: 300 },
66
+ port: 3000,
68
67
  });
69
68
 
70
- // app Hono 实例 —— 按你的运行时方式挂载
71
- import { serve } from "@hono/node-server";
72
- serve({ fetch: app.fetch, port: 3000 });
69
+ // `app` 是已启动的 Hono 实例 —— 导出给 adapter 导入 fetch handler 的 serverless 运行时
70
+ // (Vercel / Cloudflare / Netlify)。
71
+ export { app };
73
72
  ```
74
73
 
75
74
  ### 包含什么
@@ -149,48 +148,58 @@ serverless 函数下,proxy 逻辑可以内联到部署的函数 bundle 而不
149
148
 
150
149
  ## ISR(增量静态再生成)
151
150
 
151
+ 把路由标 `prerender`:路由级(`renderMode: "prerender"`)或经 Vite 插件按 glob 配置(配置级优先于路由级):
152
+
152
153
  ```ts
153
- isr: {
154
- routes: ["/blog/*", "/products/*"],
155
- ttl: 300, //
156
- }
154
+ finesoftFrontViteConfig({
155
+ ssr: { entry: "src/ssr.ts" },
156
+ renderModes: { "/blog/*": "prerender", "/products/*": "prerender" },
157
+ });
157
158
  ```
158
159
 
159
- 工作原理:
160
+ `prerender` 路由有两种服务方式:
160
161
 
161
- 1. `/blog/hello-world` 的首次请求:完整渲染、缓存 HTML、过期时间设为 now + 300s
162
- 2. TTL 内的后续请求:直接服务缓存的 HTML
163
- 3. 过期后:下个请求触发重渲染;并发请求拿到陈旧 HTML 直到重渲染完成
162
+ 1. **构建期静态化** —— static adapter 在 build 时渲染每个 prerender 路由、写 `dist/<route>.html`(开 i18n 时按 locale 各一份)。当作纯静态文件服务,请求时不跑 controller。
163
+ 2. **运行时缓存** —— 打包服务器(`createServer`)和 `vp preview` 在路由**首次**请求时渲染、把 HTML 存入内存 LRU(`ISR_CACHE_MAX = 1000` 条,按最近最少使用驱逐)。后续请求直接吐缓存、不再跑 controller。
164
164
 
165
- 缓存按服务器实例存内存。多实例部署且一致性重要时,前置 CDN HTTP `Cache-Control` 头。
165
+ > **无 TTL、无后台再生成。** 运行时缓存没有基于时间的过期、也没有 stale-while-revalidate —— 条目存活到被 LRU 驱逐或进程重启。「N 秒后再生成」的语义在 **CDN**、不在框架(见下)。没有 `isr` 配置项,也没有程序化失效 API。
166
166
 
167
- `isr.routes` 不匹配的路由总是新渲染。
167
+ ### stale-while-revalidate 委托给 CDN
168
168
 
169
- ### 缓存失效
169
+ 平台 adapter 在 prerender 响应上设缓存头,让边缘做真正的 ISR:
170
170
 
171
- 公共 API 不暴露程序化失效。要强制刷新:
171
+ | Adapter | prerender 响应的缓存头 |
172
+ | ---------------------- | ----------------------------------------------------------------------------------------- |
173
+ | Netlify | `Netlify-CDN-Cache-Control: max-age=3600, stale-while-revalidate=3600, durable`(真 SWR) |
174
+ | Cloudflare | `Cache-Control: public, max-age=3600` |
175
+ | Node(自托管)/ Vercel | 无 —— 依赖内存 LRU |
176
+
177
+ `3600` 秒窗口是各 adapter 里硬编码的常量,不可配。多实例 / 多区域部署靠 CDN 头保证一致缓存;内存 LRU 是单实例单服务器的服务。
178
+
179
+ ### 缓存失效
172
180
 
173
- - 重启服务器(丢整个缓存)
174
- - 等 TTL
175
- - 加 Controller 能忽略但能绕过缓存 key 的 cache-bust query 参数
181
+ 没有程序化失效 API。要强制刷新:
176
182
 
177
- 生产把失效推到 CDN 层 —— 框架的内存缓存为单实例服务而设。
183
+ - 重启服务器(清空整个内存 LRU)
184
+ - 重新部署(重建构建期静态 + 重置缓存)
185
+ - 在 Netlify / Cloudflare 上 purge 该路径的 CDN 缓存
178
186
 
179
187
  ## 自定义 Hono 中间件
180
188
 
181
- 如果你需要 proxy 和 SSR 之外的服务端逻辑(如 webhook、健康检查),挂到同一个 Hono app 上:
189
+ 如果你需要 proxy 和 SSR 之外的服务端逻辑(如 webhook、健康检查),通过 `setup` 钩子注册 —— 它在 proxies 之后、SSR catch-all **之前**跑,所以你的路由优先:
182
190
 
183
191
  ```ts
184
- const app = createServer({ ssrEntry: "./dist/server/ssr.js" });
185
-
186
- app.get("/health", (c) => c.json({ status: "ok" }));
187
- app.post("/webhook", async (c) => {
188
- const body = await c.req.json();
189
- await handleWebhook(body);
190
- return c.json({ ok: true });
192
+ await createServer({
193
+ ssr: { ssrProductionModule: "./dist/server/ssr.js" },
194
+ setup: (app) => {
195
+ app.get("/health", (c) => c.json({ status: "ok" }));
196
+ app.post("/webhook", async (c) => {
197
+ const body = await c.req.json();
198
+ await handleWebhook(body);
199
+ return c.json({ ok: true });
200
+ });
201
+ },
191
202
  });
192
-
193
- // SSR catch-all 由 createServer 最后注册 —— 你的路由优先。
194
203
  ```
195
204
 
196
205
  ## 环境变量
@@ -215,25 +224,16 @@ framework.container.register("config", () => ({
215
224
  负载均衡器后的 Node 部署:
216
225
 
217
226
  ```ts
218
- import { serve } from "@hono/node-server";
219
-
220
- const app = createServer({
221
- /* ... */
227
+ await createServer({
228
+ ssr: { ssrProductionModule: "./dist/server/ssr.js" },
229
+ setup: (app) => app.get("/health", (c) => c.json({ ok: true })),
222
230
  });
223
- app.get("/health", (c) => c.json({ ok: true }));
224
-
225
- const server = serve({ fetch: app.fetch, port: 3000 });
226
231
 
227
- process.on("SIGTERM", () => {
228
- server.close(() => {
229
- // 如果你拿着 Framework 引用,dispose 它
230
- framework.dispose();
231
- process.exit(0);
232
- });
233
- });
232
+ // createServer 自身启动监听 —— 不需要再手动 serve()
233
+ process.on("SIGTERM", () => process.exit(0));
234
234
  ```
235
235
 
236
- `framework.dispose()` 递归 dispose 容器、对注册的 recorder/logger 调 `destroy()`、注销所有路由。
236
+ `createServer` 不返回底层 `http.Server`,因此没有内建的 `server.close()` 连接 drain。若你需要优雅 drain —— 或需要句柄在关闭时调 `framework.dispose()`(递归 dispose 容器、对 recorder/logger 调 `destroy()`、注销路由)—— 改用更底层的搭建:自己建 Hono app 和 framework 并 `serve()`,从而同时握住两个句柄。
237
237
 
238
238
  ## 下一步
239
239
 
@@ -97,31 +97,29 @@ export const navigation = defineNavigation({
97
97
 
98
98
  ### 接入浏览器
99
99
 
100
- `startBrowserApp` 新增一个可选 `navigation` 字段和一个 `onNavigationReady` 回调,把 `NavigationHandle` 交给你:
100
+ `startBrowserApp` 新增一个可选 `navigation` 字段;存在时,`NavigationHandle`(以及统一的 `app` 句柄)会在 `mount` 回调的 context 里交给你,挂载时即可直接使用:
101
101
 
102
102
  ```ts
103
103
  // src/main.ts
104
- import { startBrowserApp, type NavigationHandle } from "@finesoft/front";
104
+ import { startBrowserApp } from "@finesoft/front";
105
105
  import { bootstrap, navigation } from "./bootstrap";
106
- import { mount } from "./lib/mount";
107
-
108
- let handle: NavigationHandle;
109
106
 
110
107
  startBrowserApp({
111
108
  bootstrap,
112
- mount,
113
109
  callbacks,
114
110
  navigation: navigation.toBrowserConfig(),
115
- onNavigationReady(h) {
116
- handle = h;
117
- // 快照变更时重渲染
118
- h.subscribe((snapshot) => mountNavigation(snapshot));
119
- mountNavigation(h.getSnapshot());
111
+ mount(target, { navigation: nav, app }) {
112
+ // nav/app 在 mount 时已就绪,无需等待回调。
113
+ // 快照变更时重渲染:
114
+ nav?.subscribe((snapshot) => mountNavigation(snapshot));
115
+ if (nav) mountNavigation(nav.getSnapshot());
116
+ // ... 把 UI 挂载到 target,将 app 传给组件 ...
117
+ return () => undefined;
120
118
  },
121
119
  });
122
120
  ```
123
121
 
124
- 提供 `navigation` 时,框架会构建 `NavigationController` 和 history 桥、解析首屏、把 handle 交给你。缺省时 `startBrowserApp` 走原有扁平单页路径,行为不变。
122
+ 提供 `navigation` 时,框架会构建 `NavigationController` 和 history 桥、解析首屏,并在 mount context 中把 handle 交给你。缺省时 `startBrowserApp` 走原有扁平单页路径,行为不变。
125
123
 
126
124
  ## 驱动导航
127
125
 
@@ -299,6 +297,8 @@ function renderApp(page, framework, snapshot) {
299
297
  }
300
298
  ```
301
299
 
300
+ `renderApp` 具体要搭的 islands 外壳 —— chrome + 按目标的 islands 作为独立水合 root,以及客户端 `mountEntry` / `resolveIslandsShell` 如何收养并水合它们 —— 见 [Islands SSR](./04-rendering-and-hydration.md#islands-ssr结构化架构方案-c)。
301
+
302
302
  底层原理:每个可见目标经**既有的** `PrefetchedIntents` 通道序列化为一条普通的 `{ intent, data: page }`,再额外挂一条承载序列化树的哨兵条目。`@finesoft/server` **零改动** —— 它经同一个 `#serialized-server-data` 脚本透传哨兵。hydration 时浏览器桥从 history state(或哨兵)读回树,并复用预取的页面。
303
303
 
304
304
  若某请求没有结构化深链、应用也没提供骨架,SSR 回退到 `Router.resolve(url)` → 单个叶子 —— 即今天的扁平单页(含其 `renderMode`)。404 路径不变。
@@ -352,4 +352,4 @@ export const navigation = defineNavigation({
352
352
  ## 下一步
353
353
 
354
354
  - [中间件](./03-middleware.md) —— 导航复用的守卫语义
355
- - [渲染与 Hydration](./04-rendering-and-hydration.md) —— 预取结果如何跨越 SSR → CSR 边界
355
+ - [渲染与 Hydration](./04-rendering-and-hydration.md) —— 渲染模式 × 架构矩阵、islands SSR 外壳,以及预取结果如何跨越 SSR → CSR 边界
@@ -0,0 +1,219 @@
1
+ # 12. 会话恢复
2
+
3
+ 框架已经能恢复**首屏**:SSR 把 prefetch 出来的 intent 结果经 `PrefetchedIntents` 注入 HTML,浏览器在首次导航时复用它们;结构化导航的当前树也会随 `history.state` 走前进 / 后退。
4
+
5
+ 但有一类状态以上手段**全都救不回**:用户**硬重载 / 标签崩溃 / 关掉再回来**时「当时在干什么」—— 他在哪一屏(或哪个栈深、哪个 tab、split 哪一列)、表单里打了一半的草稿、列表滚到哪里。内存里的 `history.state` map 整页重载即清空,`PrefetchedIntents` 只覆盖服务端渲染的那一屏。
6
+
7
+ **会话恢复**填这个缺口:把一份带版本、JSON 安全的**会话快照**(导航位置 + 应用注册的状态切片 + 导航作用域的逐屏状态)序列化到可插拔 `Storage`,并在全新加载时重水化。框架**不含任何 UI** —— 它只恢复**状态**,应用据此自行重渲染。
8
+
9
+ 它完全可选:从不向 `startBrowserApp` 传 `session` 的应用**逐位等价**于原行为,毫无变化。
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` 依赖
package/package.json CHANGED
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "name": "@finesoft/front",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Full-stack framework: router, DI, actions, SSR, and server — all in one package",
5
5
  "license": "MIT",
6
6
  "files": [
7
+ "dist/browser-BIetJHp_.mjs",
8
+ "dist/browser-DMw76sEo.d.mts",
7
9
  "dist/browser.d.mts",
8
10
  "dist/browser.mjs",
9
11
  "dist/index.d.mts",
10
12
  "dist/index.mjs",
11
- "dist/server-data-HVSgxEac.d.mts",
12
- "dist/src-BI4eMjHk.mjs",
13
13
  "docs",
14
14
  "README.md"
15
15
  ],