@finesoft/front 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/README.md +4 -4
  2. package/dist/Outlet.svelte +39 -0
  3. package/dist/Outlet.svelte.d.ts +7 -0
  4. package/dist/browser-DIU6Sxl3.mjs +1237 -0
  5. package/dist/browser-kFMjlLGT.d.mts +262 -0
  6. package/dist/browser.d.mts +7 -2
  7. package/dist/browser.mjs +9 -1
  8. package/dist/controller-types-CgmJ6-le.d.mts +16 -0
  9. package/dist/cookies-Bpf9VayB.d.mts +779 -0
  10. package/dist/fetch-policy-BHT8RtrL.mjs +82 -0
  11. package/dist/host-guard-DDWxLpFL.mjs +222 -0
  12. package/dist/http-B6CJqDyf.d.mts +46 -0
  13. package/dist/http-CaxrMD1A.d.mts +1 -0
  14. package/dist/http-D70PL72H.mjs +257 -0
  15. package/dist/http.d.mts +3 -0
  16. package/dist/http.mjs +2 -0
  17. package/dist/index-node.d.mts +15 -0
  18. package/dist/index-node.mjs +15 -0
  19. package/dist/index.d.mts +48 -698
  20. package/dist/index.mjs +44 -261
  21. package/dist/load-node.d.mts +5 -0
  22. package/dist/load-node.mjs +10 -0
  23. package/dist/load-portable.d.mts +5 -0
  24. package/dist/load-portable.mjs +8 -0
  25. package/dist/lru-map-BKoUAySU.mjs +50 -0
  26. package/dist/messages-CAt2QdGr.mjs +140 -0
  27. package/dist/native-contract-DuR25hYB.d.mts +14 -0
  28. package/dist/native-contract.d.mts +2 -0
  29. package/dist/native-contract.mjs +1 -0
  30. package/dist/node-D9hB4dsz.d.mts +35 -0
  31. package/dist/node.d.mts +2 -0
  32. package/dist/node.mjs +59 -0
  33. package/dist/path-CGFl2w7D.mjs +113 -0
  34. package/dist/path-CXT6xGPO.d.mts +261 -0
  35. package/dist/portable-CaxrMD1A.d.mts +1 -0
  36. package/dist/portable.d.mts +11 -0
  37. package/dist/portable.mjs +12 -0
  38. package/dist/proxy-1SphZ7x7.mjs +436 -0
  39. package/dist/proxy-2dSWO-Xw.d.mts +53 -0
  40. package/dist/public-types-BcJM-AYc.mjs +835 -0
  41. package/dist/react-DhwBRw01.d.mts +16 -0
  42. package/dist/react.d.mts +3 -0
  43. package/dist/react.mjs +29 -0
  44. package/dist/rolldown-runtime-B4iAMlE-.mjs +35 -0
  45. package/dist/secure-fetch-Xlht2jd7.d.mts +30 -0
  46. package/dist/server-controller-proxy-BkVuVWVD.d.mts +38 -0
  47. package/dist/session-DnB4ZC3x.d.mts +1279 -0
  48. package/dist/src-Ftl_0rhu.mjs +28 -0
  49. package/dist/src-qwx7Vw8g.mjs +3807 -0
  50. package/dist/ssr-BEUNDvbj.d.mts +210 -0
  51. package/dist/ssr-C8xnYXoY.mjs +357 -0
  52. package/dist/ssr.d.mts +3 -0
  53. package/dist/ssr.mjs +3 -0
  54. package/dist/svelte-Dr5to3SE.d.mts +16 -0
  55. package/dist/svelte.d.mts +3 -0
  56. package/dist/svelte.mjs +13 -0
  57. package/dist/typegen-C-WeJCtf.d.mts +12 -0
  58. package/dist/typegen-cli.d.mts +1 -0
  59. package/dist/typegen-cli.mjs +11 -0
  60. package/dist/typegen.d.mts +3 -0
  61. package/dist/typegen.mjs +2 -0
  62. package/dist/types-BuaZHRG7.mjs +402 -0
  63. package/dist/undici-CPfL25Hr.mjs +22262 -0
  64. package/dist/vite-Cj4SPA8D.d.mts +277 -0
  65. package/dist/vite.d.mts +4 -0
  66. package/dist/vite.mjs +2354 -0
  67. package/dist/vue-DGmzuKho.d.mts +32 -0
  68. package/dist/vue.d.mts +3 -0
  69. package/dist/vue.mjs +56 -0
  70. package/dist/web.d.mts +6 -0
  71. package/dist/web.mjs +8 -0
  72. package/dist/worker.d.mts +2 -0
  73. package/dist/worker.mjs +2 -0
  74. package/docs/01-getting-started.md +67 -199
  75. package/docs/02-routing-and-controllers.md +163 -241
  76. package/docs/03-middleware.md +10 -212
  77. package/docs/04-rendering-and-hydration.md +6 -333
  78. package/docs/05-i18n.md +7 -237
  79. package/docs/06-http-client.md +20 -263
  80. package/docs/07-di-container.md +23 -257
  81. package/docs/08-observability.md +6 -286
  82. package/docs/09-server-and-deployment.md +59 -219
  83. package/docs/10-features-platform-pwa.md +7 -231
  84. package/docs/11-navigation.md +143 -288
  85. package/docs/12-session-restoration.md +6 -214
  86. package/docs/README.md +7 -7
  87. package/docs/advanced/custom-action-handler.md +29 -229
  88. package/docs/advanced/custom-adapter.md +7 -259
  89. package/docs/advanced/custom-event-recorder.md +7 -312
  90. package/docs/advanced/inline-proxy-codegen.md +6 -185
  91. package/docs/advanced/multi-tenant-scopes.md +10 -323
  92. package/docs/engineering/ci-release-flow.md +35 -222
  93. package/docs/engineering/project-structure.md +34 -277
  94. package/docs/engineering/testing.md +8 -310
  95. package/docs/pitfalls/container-scope-leak.md +2 -214
  96. package/docs/pitfalls/i18n-bundle-size.md +6 -176
  97. package/docs/pitfalls/proxy-binary-payloads.md +8 -12
  98. package/docs/pitfalls/ssr-hydration-mismatch.md +4 -160
  99. package/docs/pitfalls/ssr-vs-csr-globals.md +9 -170
  100. package/docs/zh/01-getting-started.md +67 -199
  101. package/docs/zh/02-routing-and-controllers.md +166 -244
  102. package/docs/zh/03-middleware.md +10 -212
  103. package/docs/zh/04-rendering-and-hydration.md +6 -333
  104. package/docs/zh/05-i18n.md +7 -237
  105. package/docs/zh/06-http-client.md +20 -263
  106. package/docs/zh/07-di-container.md +23 -257
  107. package/docs/zh/08-observability.md +6 -283
  108. package/docs/zh/09-server-and-deployment.md +59 -219
  109. package/docs/zh/10-features-platform-pwa.md +7 -231
  110. package/docs/zh/11-navigation.md +130 -290
  111. package/docs/zh/12-session-restoration.md +6 -214
  112. package/docs/zh/README.md +4 -4
  113. package/docs/zh/advanced/custom-action-handler.md +29 -229
  114. package/docs/zh/advanced/custom-adapter.md +7 -259
  115. package/docs/zh/advanced/custom-event-recorder.md +7 -312
  116. package/docs/zh/advanced/inline-proxy-codegen.md +6 -185
  117. package/docs/zh/advanced/multi-tenant-scopes.md +10 -323
  118. package/docs/zh/engineering/ci-release-flow.md +35 -222
  119. package/docs/zh/engineering/project-structure.md +34 -277
  120. package/docs/zh/engineering/testing.md +8 -310
  121. package/docs/zh/pitfalls/container-scope-leak.md +2 -214
  122. package/docs/zh/pitfalls/i18n-bundle-size.md +6 -176
  123. package/docs/zh/pitfalls/proxy-binary-payloads.md +8 -12
  124. package/docs/zh/pitfalls/ssr-hydration-mismatch.md +4 -160
  125. package/docs/zh/pitfalls/ssr-vs-csr-globals.md +9 -170
  126. package/package.json +118 -20
  127. package/dist/browser-BYZq9Jp7.mjs +0 -2
  128. package/dist/browser-JTs2jqVY.d.mts +0 -2811
@@ -1,242 +1,82 @@
1
- # 9. 服务器与部署
1
+ # HTTP 与部署主机
2
2
 
3
- 框架的服务端。本章覆盖:
3
+ 可移植数据处理器返回标准 Response。同一操作可绑定 Node 或 Worker,业务模块无需页面、DOM、Hono、Vite。
4
4
 
5
- - Vite 插件(`finesoftFrontViteConfig`)—— dev server、构建配置、代码生成
6
- - `createServer` —— 独立的 Hono 服务器
7
- - Proxy 路由 —— 带 SSRF / 二进制完整性守卫的声明式 API 转发
8
- - Adapter —— Node、Vercel、Cloudflare、Netlify、静态
9
-
10
- ## Vite 插件
5
+ ## Data endpoint / 数据接口
11
6
 
12
7
  ```ts
13
- // vite.config.ts
14
- import { finesoftFrontViteConfig } from "@finesoft/front";
15
- import { defineConfig } from "vite";
16
-
17
- export default defineConfig({
18
- plugins: [
19
- // ... 视图层插件(Vue/React/Svelte)
20
- finesoftFrontViteConfig({
21
- ssr: { entry: "src/ssr.ts" },
22
- i18n: { messagesDir: "src/locales" },
23
- proxies: [{ prefix: "/api", target: "https://upstream.example" }],
24
- adapter: "auto",
25
- renderModes: { "/blog/*": "prerender" },
26
- }),
27
- ],
8
+ import { defineApp, defineOperation, createRuntime, ExecutionError } from "@finesoft/front";
9
+ import { defineEndpoint } from "@finesoft/front";
10
+ export const double = defineOperation({
11
+ id: "double",
12
+ kind: "query",
13
+ handler: (value: number) => value * 2,
28
14
  });
15
+ export const endpoints = [
16
+ defineEndpoint({
17
+ method: "POST",
18
+ path: "/double",
19
+ operation: double,
20
+ decode: async (request) => {
21
+ const body: unknown = await request.json();
22
+ if (typeof body !== "number" || !Number.isFinite(body))
23
+ throw new ExecutionError("validation");
24
+ return body;
25
+ },
26
+ encode: (value) => Response.json({ value }),
27
+ }),
28
+ ];
29
+ export function createDataApp() {
30
+ const runtime = createRuntime({ app: defineApp({ id: "data-app", operations: [double] }) });
31
+ return { runtime, endpoints };
32
+ }
29
33
  ```
30
34
 
31
- ### 选项
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
- | `renderModes` | `Record<string, RenderMode>` | 按路由覆盖渲染模式(glob 键);`"prerender"` 启用 ISR 缓存。 |
40
-
41
- ### 它做什么
42
-
43
- Dev:
44
-
45
- - 启动 Hono 服务器,每个请求都跑你的 SSR 入口
46
- - 通过 Vite 模块图热重载 SSR 代码
47
- - 本地服务 proxy 路由,让客户端 `fetch("/api/...")` 能跑通
48
-
49
- Build:
50
-
51
- - 用 Vite 标准管线打包客户端 bundle
52
- - 把 SSR 入口打包成独立模块
53
- - 生成 adapter 特定的入口文件(`vercel.func`、`_worker.js`、`node-server.js` 等)
54
- - 把 `renderMode: "prerender"` 的路由预渲染成静态 HTML
55
-
56
- ## `createServer` —— 独立 Hono 服务器
57
-
58
- Node 部署和测试时,框架导出一个 async 工厂。它加载 `.env`、检测运行时、构建 Hono app、注册 proxies + 你的 `setup` 路由、挂 SSR catch-all,并**启动监听**(端口取自配置或 `PORT`,默认 `3000`)—— 然后返回 `{ app, vite, runtime }`:
35
+ ## Separate platform entries / 独立平台入口
59
36
 
60
37
  ```ts
61
- import { createServer } from "@finesoft/front";
62
-
63
- const { app } = await createServer({
64
- ssr: { ssrProductionModule: "./dist/server/ssr.js" }, // dev 用 ssrEntryPath
65
- proxies: [{ prefix: "/api", target: "https://upstream.example" }],
38
+ // node.ts
39
+ import { startNodeHandler } from "@finesoft/front";
40
+ import { createHttpHandler } from "@finesoft/front";
41
+ import { createDataApp } from "./data-app";
42
+ const options = createDataApp();
43
+ const handler = createHttpHandler(options);
44
+ const server = await startNodeHandler({
45
+ handler,
66
46
  port: 3000,
47
+ disposeApp: () => options.runtime.dispose(),
67
48
  });
49
+ // await server.dispose();
68
50
 
69
- // `app` 是已启动的 Hono 实例 —— 导出给 adapter 导入 fetch handler 的 serverless 运行时
70
- // (Vercel / Cloudflare / Netlify)。
71
- export { app };
72
- ```
73
-
74
- ### 包含什么
75
-
76
- - 客户端 bundle 的静态文件服务
77
- - 所有 proxy 路由(通过 `registerProxyRoutes` 注册)
78
- - 带完整中间件管线的 SSR 渲染
79
- - prerendered 路由的 ISR 缓存
80
- - 从 `Accept-Language` 解析 locale
81
-
82
- ## Proxy 路由
83
-
84
- 带内置 SSRF 保护、二进制安全转发、可配置 auth/cache 的声明式 API 转发。
85
-
86
- ### 基础配置
87
-
88
- ```ts
89
- proxies: [
90
- {
91
- prefix: "/api", // 必须以 / 开头
92
- target: "https://api.example.com", // 必须以 https:// 或 http:// 开头
93
- },
94
- ],
95
- ```
96
-
97
- 现在 `GET /api/users/42` → `GET https://api.example.com/users/42`。query 参数和请求头都转发。
98
-
99
- ### 完整选项
100
-
101
- ```ts
102
- {
103
- prefix: "/api/apple",
104
- target: "https://api.music.apple.com",
105
- methods: ["get", "post"], // 默认 ["all"]
106
- headers: { "X-App": "finesoft" }, // 注入到每个请求
107
- auth: { type: "bearer", envKey: "APPLE_TOKEN" }, // 读 process.env.APPLE_TOKEN
108
- cache: "public, max-age=60", // 响应的 Cache-Control
109
- followRedirects: false, // 默认 false(redirect: "manual")
110
- }
111
- ```
112
-
113
- `auth.type`:`"bearer"` → `Authorization: Bearer <token>`。`"basic"` → `Authorization: Basic <token>`。`envKey` 在请求时读取,所以改它(或 unset)不需要重启。
114
-
115
- ### 框架强制的保证
116
-
117
- - **SSRF 保护**:path 包含任何 `%` 编码字符、以 `//` 开头、或含允许字符集之外的字符(`[/\w.\-~%:@!$&'()*+,;=]`)就被拒。解码后 ≠ 原始也拒(防止 `%2F` 走私)。
118
- - **开放重定向保护**:构造出的目标 URL 必须与配置的 `target` 同 `origin`。不同 origin → `400 Invalid proxy target`。
119
- - **二进制完整性**:响应 body 通过 `arrayBuffer()` 转发,不用 `text()` —— 精确保留字节。PDF、图片、protobuf 响应与上游响应字节相同。
120
- - **大小限制**:10 MB。先查 `Content-Length` 头快速拒绝;fetch 后再查实际 body 长度。
121
- - **HTTP 警告**:任何 `http://` 目标启动时打 warning。生产用 HTTPS。
122
-
123
- ### 生成的 proxy 代码(serverless / edge)
124
-
125
- serverless 函数下,proxy 逻辑可以内联到部署的函数 bundle 而不依赖运行时的 `registerProxyRoutes`。详见 [advanced/inline-proxy-codegen](./advanced/inline-proxy-codegen.md)。
126
-
127
- ## Adapter
128
-
129
- | Adapter | 目标 | 构建产物 |
130
- | -------------- | -------------------------- | ------------------------------------------------------- |
131
- | `"node"` | 独立 Node.js 服务器 | `dist/server/index.js` —— `serve({ fetch: app.fetch })` |
132
- | `"vercel"` | Vercel Build Output API v3 | `.vercel/output/` 含 `functions/` 和 `static/` |
133
- | `"cloudflare"` | Cloudflare Workers | `dist/_worker.js` + `dist/_routes.json` |
134
- | `"netlify"` | Netlify Functions v2 | `netlify/functions/` + `_redirects` |
135
- | `"static"` | 预渲染静态文件 | 只有 `dist/client/`(无服务器) |
136
- | `"auto"` | 构建期自动检测 | 按环境变量挑上面之一 |
137
-
138
- ### 自动检测
139
-
140
- `adapter: "auto"` 按顺序检查:
141
-
142
- 1. `VERCEL=1` → vercel
143
- 2. `CF_PAGES=1` → cloudflare
144
- 3. `NETLIFY=1` → netlify
145
- 4. 否则 → node
146
-
147
- 对大多数 CI 环境管用 —— Vercel / Cloudflare / Netlify 构建期都自动设置这些。
148
-
149
- ## ISR(增量静态再生成)
150
-
151
- 把路由标 `prerender`:路由级(`renderMode: "prerender"`)或经 Vite 插件按 glob 配置(配置级优先于路由级):
152
-
153
- ```ts
154
- finesoftFrontViteConfig({
155
- ssr: { entry: "src/ssr.ts" },
156
- renderModes: { "/blog/*": "prerender", "/products/*": "prerender" },
157
- });
51
+ // worker.ts (a separate host entry)
52
+ import { createHttpHandler } from "@finesoft/front";
53
+ import { createDataApp } from "./data-app";
54
+ export default createHttpHandler(createDataApp);
158
55
  ```
159
56
 
160
- `prerender` 路由有两种服务方式:
161
-
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
-
165
- > **无 TTL、无后台再生成。** 运行时缓存没有基于时间的过期、也没有 stale-while-revalidate —— 条目存活到被 LRU 驱逐或进程重启。「N 秒后再生成」的语义在 **CDN**、不在框架(见下)。没有 `isr` 配置项,也没有程序化失效 API。
166
-
167
- ### stale-while-revalidate 委托给 CDN
168
-
169
- 平台 adapter 在 prerender 响应上设缓存头,让边缘做真正的 ISR:
170
-
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
- ### 缓存失效
180
-
181
- 没有程序化失效 API。要强制刷新:
182
-
183
- - 重启服务器(清空整个内存 LRU)
184
- - 重新部署(重建构建期静态 + 重置缓存)
185
- - 在 Netlify / Cloudflare 上 purge 该路径的 CDN 缓存
186
-
187
- ## 自定义 Hono 中间件
57
+ HTTP 处理器本身通过 `handler.fetch(request, bindings, host)` 执行请求,Node 与 Worker 使用同一对象;`startNodeHandler` 接收该对象,不接收单独的函数。工厂参数在首个请求内初始化一次,避免 workerd 在模块求值阶段创建 Runtime;每个请求的 bindings、取消与后台任务宿主仍独立传入。旧 `createWorkerHandler` 已删除。
188
58
 
189
- 如果你需要 proxy SSR 之外的服务端逻辑(如 webhook、健康检查),通过 `setup` 钩子注册 —— 它在 proxies 之后、SSR catch-all **之前**跑,所以你的路由优先:
59
+ ## Web build / 页面构建
190
60
 
191
61
  ```ts
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
- },
62
+ import { defineConfig } from "vite-plus";
63
+ import react from "@vitejs/plugin-react";
64
+ import { finesoftFrontViteConfig } from "@finesoft/front";
65
+ export default defineConfig({
66
+ plugins: [
67
+ react(),
68
+ finesoftFrontViteConfig({
69
+ adapter: "node",
70
+ ssr: { entry: "src/ssr.ts" },
71
+ }),
72
+ ],
202
73
  });
203
74
  ```
204
75
 
205
- ## 环境变量
206
-
207
- 框架读取:
208
-
209
- - `NODE_ENV` —— `"production"` 启用生产专用优化
210
- - `PROXY_TOKEN` / `BASIC_TOKEN` / 任何 `auth.envKey` —— proxy 鉴权密钥
211
- - `VERCEL`、`CF_PAGES`、`NETLIFY` —— adapter 自动检测
212
-
213
- 其他都是你的。通过 `process.env` 直接访问,或在 DI 容器里注册 config 对象:
214
-
215
- ```ts
216
- framework.container.register("config", () => ({
217
- upstreamUrl: process.env.UPSTREAM_URL ?? "https://api.example.com",
218
- sessionSecret: requireEnv("SESSION_SECRET"),
219
- }));
220
- ```
221
-
222
- ## 健康检查和优雅关闭
223
-
224
- 负载均衡器后的 Node 部署:
225
-
226
- ```ts
227
- await createServer({
228
- ssr: { ssrProductionModule: "./dist/server/ssr.js" },
229
- setup: (app) => app.get("/health", (c) => c.json({ ok: true })),
230
- });
231
-
232
- // createServer 自身启动监听 —— 不需要再手动 serve()。
233
- process.on("SIGTERM", () => process.exit(0));
234
- ```
76
+ Node 主机需要 `@hono/node-server`。可移植 Worker 图无需 Node 兼容开关。DNS 校验属于 Node 主机,必需能力不可用时明确失败。浏览器网络使用显式策略。流资源保留到消费、取消或失败。后台任务使用 `runManagedTask` 与主机 `waitUntil`,不能保留响应所有的资源。Vite 适配器生成使用同一 SSR 响应组装器的薄主机模块;本地构建不等于部署发布。
235
77
 
236
- `createServer` 不返回底层 `http.Server`,因此没有内建的 `server.close()` 连接 drain。若你需要优雅 drain —— 或需要句柄在关闭时调 `framework.dispose()`(递归 dispose 容器、对 recorder/logger 调 `destroy()`、注销路由)—— 改用更底层的搭建:自己建 Hono app 和 framework 并 `serve()`,从而同时握住两个句柄。
78
+ `setup` 是模块路径时,该模块必须通过 `export default` 导出 setup 函数。开发、预览与生成的部署主机统一使用这个导出,不再自动猜测命名函数。
237
79
 
238
- ## 下一步
80
+ ## 静态托管边界
239
81
 
240
- - [Feature flags、平台、PWA](./10-features-platform-pwa.md) —— 特性开关、平台检测
241
- - [工程实践 · CI 与发布流程](./engineering/ci-release-flow.md) —— 自动化发布
242
- - [陷阱:proxy 二进制载荷](./pitfalls/proxy-binary-payloads.md) —— 为什么 `arrayBuffer` 重要
82
+ `staticAdapter` 默认读取构建产物的 `render.routes`,通过同一 SSR host 生成 HTML 并等待释放;`dynamicRoutes` 提供具体动态路径,`routesExport` 仅作显式扩展。发现路由或渲染失败会令构建失败。纯 HTML 不能表达重定向、错误状态、Set-Cookie 或自定义 HTTP 响应头,因此适配器拒绝这些响应;需要它们时选择 Node/Worker 等请求主机。
@@ -1,238 +1,14 @@
1
- # 10. Feature flags、平台、PWA
1
+ # 功能开关、平台与 PWA
2
2
 
3
- 三个小而独立的运行时辅助:
3
+ 可移植检测、功能开关契约属于根入口;PWA、DOM 行为使用浏览器入口。
4
4
 
5
- - **Feature flags** —— 不重新部署就能变的配置
6
- - **平台检测** —— 解析 User-Agent 得到 OS / 浏览器 / 引擎
7
- - **PWA 模式** —— 检测应用是否已安装(standalone)
8
-
9
- 每个都可替换,每个都可组合自定义 provider。
10
-
11
- ## Feature flags
12
-
13
- ```ts
14
- const framework = Framework.create({
15
- featureFlags: {
16
- darkMode: true,
17
- maxRetries: 3,
18
- experimentalCheckout: false,
19
- },
20
- });
21
-
22
- const flags = framework.container.resolve(DEP_KEYS.FEATURE_FLAGS);
23
- flags.get("darkMode"); // true
24
- flags.get("maxRetries"); // 3
25
- flags.get("missing"); // undefined
26
- flags.get("missing", "fallback"); // "fallback"
27
- ```
28
-
29
- Flag 值可以是任何 JSON 可序列化的:布尔、字符串、数字、数组、对象。
30
-
31
- ### 静态配置
32
-
33
- 最简单的情况 —— flag 跟 bundle 一起发:
34
-
35
- ```ts
36
- Framework.create({
37
- featureFlags: {
38
- darkMode: process.env.NODE_ENV !== "production",
39
- analytics: true,
40
- cdnUrl: "https://cdn.example.com",
41
- },
42
- });
43
- ```
44
-
45
- 适用于由环境驱动的 flag,而不是用户属性驱动的。
46
-
47
- ### 远端 provider
48
-
49
- 接入从远端服务拉的 provider(LaunchDarkly、GrowthBook、Unleash、自家配置服务):
50
-
51
- ```ts
52
- import { type FeatureFlagsProvider } from "@finesoft/front";
53
-
54
- const remoteConfigProvider: FeatureFlagsProvider = {
55
- async load() {
56
- const resp = await fetch("https://config.example.com/flags");
57
- return resp.json(); // { ...flags }
58
- },
59
- };
60
-
61
- const framework = Framework.create({
62
- featureFlags: {
63
- darkMode: false,
64
- maxRetries: 3,
65
- },
66
- featureFlagsProviders: [remoteConfigProvider],
67
- });
68
- ```
69
-
70
- provider 按注册顺序跑。后注册的 provider 对同 key 覆盖之前的值 —— 「后注册者胜」。
71
-
72
- ### 缓存生命周期
73
-
74
- 框架在 `Framework.create()` 期间加载一次 provider 值。之后 flag 同步从内存读。
75
-
76
- 调 `flags.refresh()` 刷新:
77
-
78
- ```ts
79
- const flags = framework.container.resolve(DEP_KEYS.FEATURE_FLAGS);
80
- await flags.refresh(); // 重跑所有 provider
81
- ```
82
-
83
- 通常按定时器或响应服务端推送事件调。
84
-
85
- ### 用户级 targeting
86
-
87
- 内置 flag 是全局的(每个用户同一个值)。需要用户级 targeting 的话,让你的 provider 返回函数,或用单独的评估步骤:
88
-
89
- ```ts
90
- class TargetingProvider implements FeatureFlagsProvider {
91
- constructor(private userId: string) {}
92
- async load() {
93
- const resp = await fetch(`https://config.example.com/flags?userId=${this.userId}`);
94
- return resp.json();
95
- }
96
- }
97
-
98
- // 在 beforeLoad 守卫里按请求注册:
99
- async function flagsGuard(ctx) {
100
- const userId = await getUserIdFromCookie(ctx);
101
- const targeting = new TargetingProvider(userId);
102
- const flags = await targeting.load();
103
- ctx.container.register(DEP_KEYS.FEATURE_FLAGS, () => ({
104
- get: (key, fallback) => flags[key] ?? fallback,
105
- }));
106
- return next();
107
- }
108
- ```
109
-
110
- 复杂分桶交给专门的服务(GrowthBook SDK 等),把它的 evaluator 存进 DI。
111
-
112
- ### SSR / CSR 一致性
113
-
114
- 服务端评估而浏览器端不重新评估的 flag 会引起 hydration 不匹配。Controller 读 flag 时框架会把 flag 值序列化进 `PrefetchedIntents`。浏览器读到的就是服务端看到的值。
115
-
116
- 对于*应当*不同的 flag(如 A/B 变体),在 `beforeLoad` 守卫里评估并存到请求 scope 里 —— 服务端和浏览器都会用服务端解析出的值。
117
-
118
- ## 平台检测
119
-
120
- ```ts
121
- import { detectPlatform } from "@finesoft/front";
122
-
123
- const info = detectPlatform();
124
- // {
125
- // os: "ios" | "android" | "macos" | "windows" | "linux" | "other",
126
- // browser: "safari" | "chrome" | "firefox" | "edge" | ...,
127
- // engine: "webkit" | "blink" | "gecko" | "other",
128
- // isMobile: boolean,
129
- // isTouch: boolean,
130
- // isServer: boolean,
131
- // }
132
- ```
133
-
134
- 浏览器端 `detectPlatform()` 读 `navigator.userAgent`。服务端框架自动解析请求的 `User-Agent` 头:
135
-
136
- ```ts
137
- const platform = framework.getPlatform();
138
- ```
139
-
140
- Controller 和守卫从 DI resolve:
141
-
142
- ```ts
143
- const platform = ctx.container.resolve(DEP_KEYS.PLATFORM);
144
- if (platform.isMobile) {
145
- return rewrite("/m" + ctx.url.pathname);
146
- }
147
- ```
148
-
149
- ### 可靠性
150
-
151
- User-Agent 字符串会撒谎 —— 每个现代浏览器为了兼容性嵌入了其他浏览器的子串。框架的检测优先匹配众所周知的模式,遇到模糊就回退到 `"other"`。别单纯靠 `browser` 做关键决策:
152
-
153
- - ✅ 按 `isMobile` 调整布局
154
- - ✅ 非 WebKit 浏览器隐藏 Safari 专有特性
155
- - ❌ 锁定特定浏览器
156
- - ❌ 按浏览器版本选代码路径
157
-
158
- ## PWA 检测
5
+ ## Imports / 入口
159
6
 
160
7
  ```ts
8
+ import { detectPlatform, type FeatureFlagsProvider } from "@finesoft/front";
161
9
  import { getPWADisplayMode } from "@finesoft/front";
162
-
163
- const mode = getPWADisplayMode();
164
- // "standalone" | "twa" | "browser"
165
- ```
166
-
167
- - `"standalone"` —— 作为已安装 PWA 跑(Safari 加到主屏、Chrome install)
168
- - `"twa"` —— Trusted Web Activity(Android,包装成原生应用)
169
- - `"browser"` —— 普通浏览器标签
170
-
171
- 函数读 `window.matchMedia("(display-mode: standalone)")` 和 Android 的 TWA referrer。服务端返回 `"browser"`。
172
-
173
- ### 常见用途
174
-
175
- ```ts
176
- const mode = getPWADisplayMode();
177
-
178
- if (mode === "browser") {
179
- showInstallBanner();
180
- }
181
-
182
- if (mode === "standalone") {
183
- // 自定义导航 —— 已安装的应用不应再显示 install 提示
184
- hideInstallButton();
185
- enableNativeBackButtonHandling();
186
- }
187
- ```
188
-
189
- ### Service worker 注册
190
-
191
- PWA install 与 service worker 独立 —— 可以只要一个。注册 service worker:
192
-
193
- ```ts
194
- // src/main.ts
195
- startBrowserApp({
196
- bootstrap,
197
- mount,
198
- onAfterStart() {
199
- if ("serviceWorker" in navigator) {
200
- navigator.serviceWorker.register("/sw.js");
201
- }
202
- },
203
- });
204
- ```
205
-
206
- 框架不内置 service worker 生成器。用 [Vite PWA](https://vite-pwa-org.netlify.app/) 或手写。
207
-
208
- ## 组合三者
209
-
210
- 一个组合三者的常见导航守卫:
211
-
212
- ```ts
213
- import { next, rewrite, DEP_KEYS } from "@finesoft/front";
214
-
215
- function mobilePwaGuard(ctx) {
216
- const platform = ctx.container.resolve(DEP_KEYS.PLATFORM);
217
- const flags = ctx.container.resolve(DEP_KEYS.FEATURE_FLAGS);
218
-
219
- if (flags.get("mobilePwaRedesign") && platform.isMobile && !ctx.isSsr) {
220
- if (getPWADisplayMode() === "standalone") {
221
- return rewrite(`/pwa${ctx.url.pathname}`);
222
- }
223
- }
224
- return next();
225
- }
10
+ // Configure configuration.featureFlags and configuration.platform in the Web declaration.
11
+ // Call getPWADisplayMode only in a browser-owned lifecycle.
226
12
  ```
227
13
 
228
- 把已安装移动 PWA 用户路由到不同的页面树,不影响其他用户。
229
-
230
- ## 注意事项
231
-
232
- - **服务端解析出的 flag 会进 HTML。** flag 里别存秘密。
233
- - **服务端平台检测靠请求头。** 爬虫或 curl 可能没发有用的 User-Agent —— 优雅处理 `"other"`。
234
- - **服务端 PWA 检测永远返回 `"browser"`。** 不要在 SSR 渲染路径里依赖;按 PWA 模式做条件 UI 应该仅客户端,或用 `<noscript>` 兜底。
235
-
236
- ## 下一步
237
-
238
- - [工程实践 · 项目结构](./engineering/project-structure.md) —— flag config、平台感知代码放哪
14
+ 功能开关是应用决策,不是身份授权;受保护操作仍需运行时策略。Service Worker 清单、缓存策略由应用决定,选择浏览器主机不会自动安装。