@finesoft/front 0.1.76 → 0.1.78
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/docs/01-getting-started.md +230 -0
- package/docs/02-routing-and-controllers.md +203 -0
- package/docs/03-middleware.md +220 -0
- package/docs/04-rendering-and-hydration.md +271 -0
- package/docs/05-i18n.md +243 -0
- package/docs/06-http-client.md +286 -0
- package/docs/07-di-container.md +264 -0
- package/docs/08-observability.md +290 -0
- package/docs/09-server-and-deployment.md +242 -0
- package/docs/10-features-platform-pwa.md +238 -0
- package/docs/README.md +72 -0
- package/docs/advanced/custom-action-handler.md +248 -0
- package/docs/advanced/custom-adapter.md +264 -0
- package/docs/advanced/custom-event-recorder.md +318 -0
- package/docs/advanced/inline-proxy-codegen.md +200 -0
- package/docs/advanced/multi-tenant-scopes.md +330 -0
- package/docs/engineering/ci-release-flow.md +244 -0
- package/docs/engineering/project-structure.md +296 -0
- package/docs/engineering/testing.md +317 -0
- package/docs/pitfalls/container-scope-leak.md +215 -0
- package/docs/pitfalls/i18n-bundle-size.md +182 -0
- package/docs/pitfalls/proxy-binary-payloads.md +133 -0
- package/docs/pitfalls/redirect-vs-rewrite.md +147 -0
- package/docs/pitfalls/ssr-hydration-mismatch.md +163 -0
- package/docs/pitfalls/ssr-vs-csr-globals.md +176 -0
- package/docs/zh/01-getting-started.md +230 -0
- package/docs/zh/02-routing-and-controllers.md +203 -0
- package/docs/zh/03-middleware.md +220 -0
- package/docs/zh/04-rendering-and-hydration.md +271 -0
- package/docs/zh/05-i18n.md +243 -0
- package/docs/zh/06-http-client.md +286 -0
- package/docs/zh/07-di-container.md +264 -0
- package/docs/zh/08-observability.md +287 -0
- package/docs/zh/09-server-and-deployment.md +242 -0
- package/docs/zh/10-features-platform-pwa.md +238 -0
- package/docs/zh/README.md +72 -0
- package/docs/zh/advanced/custom-action-handler.md +248 -0
- package/docs/zh/advanced/custom-adapter.md +264 -0
- package/docs/zh/advanced/custom-event-recorder.md +318 -0
- package/docs/zh/advanced/inline-proxy-codegen.md +200 -0
- package/docs/zh/advanced/multi-tenant-scopes.md +330 -0
- package/docs/zh/engineering/ci-release-flow.md +244 -0
- package/docs/zh/engineering/project-structure.md +296 -0
- package/docs/zh/engineering/testing.md +317 -0
- package/docs/zh/pitfalls/container-scope-leak.md +215 -0
- package/docs/zh/pitfalls/i18n-bundle-size.md +182 -0
- package/docs/zh/pitfalls/proxy-binary-payloads.md +133 -0
- package/docs/zh/pitfalls/redirect-vs-rewrite.md +147 -0
- package/docs/zh/pitfalls/ssr-hydration-mismatch.md +163 -0
- package/docs/zh/pitfalls/ssr-vs-csr-globals.md +176 -0
- package/package.json +2 -1
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# 9. 服务器与部署
|
|
2
|
+
|
|
3
|
+
框架的服务端。本章覆盖:
|
|
4
|
+
|
|
5
|
+
- Vite 插件(`finesoftFrontViteConfig`)—— dev server、构建配置、代码生成
|
|
6
|
+
- `createServer` —— 独立的 Hono 服务器
|
|
7
|
+
- Proxy 路由 —— 带 SSRF / 二进制完整性守卫的声明式 API 转发
|
|
8
|
+
- Adapter —— Node、Vercel、Cloudflare、Netlify、静态
|
|
9
|
+
|
|
10
|
+
## Vite 插件
|
|
11
|
+
|
|
12
|
+
```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
|
+
isr: { routes: ["/blog/*"], ttl: 300 },
|
|
26
|
+
}),
|
|
27
|
+
],
|
|
28
|
+
});
|
|
29
|
+
```
|
|
30
|
+
|
|
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
|
+
| `isr` | `{ routes, ttl }` | Prerendered 路由的 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 部署和测试时,框架导出一个函数给你一个开箱即用的 Hono app:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import { createServer } from "@finesoft/front";
|
|
62
|
+
|
|
63
|
+
const app = createServer({
|
|
64
|
+
ssrEntry: "./dist/server/ssr.js",
|
|
65
|
+
proxies: [{ prefix: "/api", target: "https://upstream.example" }],
|
|
66
|
+
staticDir: "./dist/client",
|
|
67
|
+
isr: { routes: ["/blog/*"], ttl: 300 },
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// app 是 Hono 实例 —— 按你的运行时方式挂载
|
|
71
|
+
import { serve } from "@hono/node-server";
|
|
72
|
+
serve({ fetch: app.fetch, port: 3000 });
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### 包含什么
|
|
76
|
+
|
|
77
|
+
- 客户端 bundle 的静态文件服务
|
|
78
|
+
- 所有 proxy 路由(通过 `registerProxyRoutes` 注册)
|
|
79
|
+
- 带完整中间件管线的 SSR 渲染
|
|
80
|
+
- prerendered 路由的 ISR 缓存
|
|
81
|
+
- 从 `Accept-Language` 解析 locale
|
|
82
|
+
|
|
83
|
+
## Proxy 路由
|
|
84
|
+
|
|
85
|
+
带内置 SSRF 保护、二进制安全转发、可配置 auth/cache 的声明式 API 转发。
|
|
86
|
+
|
|
87
|
+
### 基础配置
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
proxies: [
|
|
91
|
+
{
|
|
92
|
+
prefix: "/api", // 必须以 / 开头
|
|
93
|
+
target: "https://api.example.com", // 必须以 https:// 或 http:// 开头
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
现在 `GET /api/users/42` → `GET https://api.example.com/users/42`。query 参数和请求头都转发。
|
|
99
|
+
|
|
100
|
+
### 完整选项
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
{
|
|
104
|
+
prefix: "/api/apple",
|
|
105
|
+
target: "https://api.music.apple.com",
|
|
106
|
+
methods: ["get", "post"], // 默认 ["all"]
|
|
107
|
+
headers: { "X-App": "finesoft" }, // 注入到每个请求
|
|
108
|
+
auth: { type: "bearer", envKey: "APPLE_TOKEN" }, // 读 process.env.APPLE_TOKEN
|
|
109
|
+
cache: "public, max-age=60", // 响应的 Cache-Control
|
|
110
|
+
followRedirects: false, // 默认 false(redirect: "manual")
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`auth.type`:`"bearer"` → `Authorization: Bearer <token>`。`"basic"` → `Authorization: Basic <token>`。`envKey` 在请求时读取,所以改它(或 unset)不需要重启。
|
|
115
|
+
|
|
116
|
+
### 框架强制的保证
|
|
117
|
+
|
|
118
|
+
- **SSRF 保护**:path 包含任何 `%` 编码字符、以 `//` 开头、或含允许字符集之外的字符(`[/\w.\-~%:@!$&'()*+,;=]`)就被拒。解码后 ≠ 原始也拒(防止 `%2F` 走私)。
|
|
119
|
+
- **开放重定向保护**:构造出的目标 URL 必须与配置的 `target` 同 `origin`。不同 origin → `400 Invalid proxy target`。
|
|
120
|
+
- **二进制完整性**:响应 body 通过 `arrayBuffer()` 转发,不用 `text()` —— 精确保留字节。PDF、图片、protobuf 响应与上游响应字节相同。
|
|
121
|
+
- **大小限制**:10 MB。先查 `Content-Length` 头快速拒绝;fetch 后再查实际 body 长度。
|
|
122
|
+
- **HTTP 警告**:任何 `http://` 目标启动时打 warning。生产用 HTTPS。
|
|
123
|
+
|
|
124
|
+
### 生成的 proxy 代码(serverless / edge)
|
|
125
|
+
|
|
126
|
+
serverless 函数下,proxy 逻辑可以内联到部署的函数 bundle 而不依赖运行时的 `registerProxyRoutes`。详见 [advanced/inline-proxy-codegen](./advanced/inline-proxy-codegen.md)。
|
|
127
|
+
|
|
128
|
+
## Adapter
|
|
129
|
+
|
|
130
|
+
| Adapter | 目标 | 构建产物 |
|
|
131
|
+
| -------------- | -------------------------- | ------------------------------------------------------- |
|
|
132
|
+
| `"node"` | 独立 Node.js 服务器 | `dist/server/index.js` —— `serve({ fetch: app.fetch })` |
|
|
133
|
+
| `"vercel"` | Vercel Build Output API v3 | `.vercel/output/` 含 `functions/` 和 `static/` |
|
|
134
|
+
| `"cloudflare"` | Cloudflare Workers | `dist/_worker.js` + `dist/_routes.json` |
|
|
135
|
+
| `"netlify"` | Netlify Functions v2 | `netlify/functions/` + `_redirects` |
|
|
136
|
+
| `"static"` | 预渲染静态文件 | 只有 `dist/client/`(无服务器) |
|
|
137
|
+
| `"auto"` | 构建期自动检测 | 按环境变量挑上面之一 |
|
|
138
|
+
|
|
139
|
+
### 自动检测
|
|
140
|
+
|
|
141
|
+
`adapter: "auto"` 按顺序检查:
|
|
142
|
+
|
|
143
|
+
1. `VERCEL=1` → vercel
|
|
144
|
+
2. `CF_PAGES=1` → cloudflare
|
|
145
|
+
3. `NETLIFY=1` → netlify
|
|
146
|
+
4. 否则 → node
|
|
147
|
+
|
|
148
|
+
对大多数 CI 环境管用 —— Vercel / Cloudflare / Netlify 构建期都自动设置这些。
|
|
149
|
+
|
|
150
|
+
## ISR(增量静态再生成)
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
isr: {
|
|
154
|
+
routes: ["/blog/*", "/products/*"],
|
|
155
|
+
ttl: 300, // 秒
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
工作原理:
|
|
160
|
+
|
|
161
|
+
1. `/blog/hello-world` 的首次请求:完整渲染、缓存 HTML、过期时间设为 now + 300s
|
|
162
|
+
2. TTL 内的后续请求:直接服务缓存的 HTML
|
|
163
|
+
3. 过期后:下个请求触发重渲染;并发请求拿到陈旧 HTML 直到重渲染完成
|
|
164
|
+
|
|
165
|
+
缓存按服务器实例存内存。多实例部署且一致性重要时,前置 CDN 用 HTTP `Cache-Control` 头。
|
|
166
|
+
|
|
167
|
+
`isr.routes` 不匹配的路由总是新渲染。
|
|
168
|
+
|
|
169
|
+
### 缓存失效
|
|
170
|
+
|
|
171
|
+
公共 API 不暴露程序化失效。要强制刷新:
|
|
172
|
+
|
|
173
|
+
- 重启服务器(丢整个缓存)
|
|
174
|
+
- 等 TTL
|
|
175
|
+
- 加 Controller 能忽略但能绕过缓存 key 的 cache-bust query 参数
|
|
176
|
+
|
|
177
|
+
生产把失效推到 CDN 层 —— 框架的内存缓存为单实例服务而设。
|
|
178
|
+
|
|
179
|
+
## 自定义 Hono 中间件
|
|
180
|
+
|
|
181
|
+
如果你需要 proxy 和 SSR 之外的服务端逻辑(如 webhook、健康检查),挂到同一个 Hono app 上:
|
|
182
|
+
|
|
183
|
+
```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 });
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// SSR catch-all 由 createServer 最后注册 —— 你的路由优先。
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## 环境变量
|
|
197
|
+
|
|
198
|
+
框架读取:
|
|
199
|
+
|
|
200
|
+
- `NODE_ENV` —— `"production"` 启用生产专用优化
|
|
201
|
+
- `PROXY_TOKEN` / `BASIC_TOKEN` / 任何 `auth.envKey` —— proxy 鉴权密钥
|
|
202
|
+
- `VERCEL`、`CF_PAGES`、`NETLIFY` —— adapter 自动检测
|
|
203
|
+
|
|
204
|
+
其他都是你的。通过 `process.env` 直接访问,或在 DI 容器里注册 config 对象:
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
framework.container.register("config", () => ({
|
|
208
|
+
upstreamUrl: process.env.UPSTREAM_URL ?? "https://api.example.com",
|
|
209
|
+
sessionSecret: requireEnv("SESSION_SECRET"),
|
|
210
|
+
}));
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## 健康检查和优雅关闭
|
|
214
|
+
|
|
215
|
+
负载均衡器后的 Node 部署:
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
import { serve } from "@hono/node-server";
|
|
219
|
+
|
|
220
|
+
const app = createServer({
|
|
221
|
+
/* ... */
|
|
222
|
+
});
|
|
223
|
+
app.get("/health", (c) => c.json({ ok: true }));
|
|
224
|
+
|
|
225
|
+
const server = serve({ fetch: app.fetch, port: 3000 });
|
|
226
|
+
|
|
227
|
+
process.on("SIGTERM", () => {
|
|
228
|
+
server.close(() => {
|
|
229
|
+
// 如果你拿着 Framework 引用,dispose 它
|
|
230
|
+
framework.dispose();
|
|
231
|
+
process.exit(0);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
`framework.dispose()` 递归 dispose 容器、对注册的 recorder/logger 调 `destroy()`、注销所有路由。
|
|
237
|
+
|
|
238
|
+
## 下一步
|
|
239
|
+
|
|
240
|
+
- [Feature flags、平台、PWA](./10-features-platform-pwa.md) —— 特性开关、平台检测
|
|
241
|
+
- [工程实践 · CI 与发布流程](./engineering/ci-release-flow.md) —— 自动化发布
|
|
242
|
+
- [陷阱:proxy 二进制载荷](./pitfalls/proxy-binary-payloads.md) —— 为什么 `arrayBuffer` 重要
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# 10. Feature flags、平台、PWA
|
|
2
|
+
|
|
3
|
+
三个小而独立的运行时辅助:
|
|
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 检测
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
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
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
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、平台感知代码放哪
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# `@finesoft/front` 文档
|
|
2
|
+
|
|
3
|
+
> **语言:** **简体中文**(当前页) · [English](../README.md)
|
|
4
|
+
|
|
5
|
+
全栈 TypeScript 框架 —— 路由、DI、Action、SSR、服务器,一个包搞定。支持 **Vue**、**React**、**Svelte**。可部署到 Node.js、Vercel、Cloudflare Workers、Netlify 或静态托管。
|
|
6
|
+
|
|
7
|
+
## 三个入口
|
|
8
|
+
|
|
9
|
+
按需要选择起点。
|
|
10
|
+
|
|
11
|
+
### 新手 —— 按顺序读完
|
|
12
|
+
|
|
13
|
+
线性路径。每章都基于前一章。读完你能构建、渲染并部署一个真实应用。
|
|
14
|
+
|
|
15
|
+
1. [快速开始](./01-getting-started.md) —— 安装、Vite 配置、第一个页面
|
|
16
|
+
2. [路由与 Controller](./02-routing-and-controllers.md) —— 路由定义、Intent、Controller、渲染模式
|
|
17
|
+
3. [中间件](./03-middleware.md) —— `beforeLoad` / `afterLoad`、redirect / rewrite / deny
|
|
18
|
+
4. [渲染与 Hydration](./04-rendering-and-hydration.md) —— SSR / CSR / Prerender、`PrefetchedIntents`
|
|
19
|
+
5. [国际化](./05-i18n.md) —— `locale`、`Translator`、字典加载、RTL
|
|
20
|
+
6. [HTTP 客户端](./06-http-client.md) —— `HttpClient` 子类化、拦截器、`HttpError`
|
|
21
|
+
7. [DI 容器](./07-di-container.md) —— 注册、scope、`DEP_KEYS`、dispose
|
|
22
|
+
8. [可观测性](./08-observability.md) —— `Logger`、`EventRecorder`、Impression 追踪、`ReportCallback`
|
|
23
|
+
9. [服务器与部署](./09-server-and-deployment.md) —— `createServer`、proxy、adapter、Vite 插件
|
|
24
|
+
10. [Feature flags、平台、PWA](./10-features-platform-pwa.md) —— 特性开关、平台检测、PWA 模式
|
|
25
|
+
|
|
26
|
+
### 已经在维护项目的工程师 —— 直接看实践
|
|
27
|
+
|
|
28
|
+
横切关注点和约定。先理解基础后再读。
|
|
29
|
+
|
|
30
|
+
- [项目结构](./engineering/project-structure.md) —— 推荐布局、`bootstrap.ts` 拆分、单一来源
|
|
31
|
+
- [测试](./engineering/testing.md) —— Controller、中间件、scoped DI、mock 框架
|
|
32
|
+
- [CI 与发布流程](./engineering/ci-release-flow.md) —— changesets、内联发布 workflow、版本对账
|
|
33
|
+
|
|
34
|
+
### 碰到问题 —— 直接看陷阱
|
|
35
|
+
|
|
36
|
+
每一篇都是 **症状 → 根因 → 修法**,简短。
|
|
37
|
+
|
|
38
|
+
- [SSR Hydration 不匹配](./pitfalls/ssr-hydration-mismatch.md)
|
|
39
|
+
- [SSR 与 CSR 的全局变量](./pitfalls/ssr-vs-csr-globals.md)
|
|
40
|
+
- [Redirect 与 Rewrite](./pitfalls/redirect-vs-rewrite.md)
|
|
41
|
+
- [Proxy 二进制载荷](./pitfalls/proxy-binary-payloads.md)
|
|
42
|
+
- [Container scope 泄漏](./pitfalls/container-scope-leak.md)
|
|
43
|
+
- [i18n 包体积](./pitfalls/i18n-bundle-size.md)
|
|
44
|
+
|
|
45
|
+
### 扩展框架 —— 高阶配方
|
|
46
|
+
|
|
47
|
+
每个配方都是一个完整、可运行的扩展示例,附说明。
|
|
48
|
+
|
|
49
|
+
- [自定义 Action handler](./advanced/custom-action-handler.md) —— 超越 `FlowAction` / `ExternalUrlAction`
|
|
50
|
+
- [自定义 Event recorder](./advanced/custom-event-recorder.md) —— 接入 Sentry / Datadog / 自有管线
|
|
51
|
+
- [自定义 adapter](./advanced/custom-adapter.md) —— 适配新平台
|
|
52
|
+
- [内联 proxy 代码生成](./advanced/inline-proxy-codegen.md) —— 为 serverless / edge 生成自包含的 proxy 路由
|
|
53
|
+
- [多租户 scope](./advanced/multi-tenant-scopes.md) —— 按租户隔离 DI 容器
|
|
54
|
+
|
|
55
|
+
## 一图看懂
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
URL/Action → Router.resolve()
|
|
59
|
+
→ beforeLoad chain (NavigationContext: redirect/rewrite/deny/next)
|
|
60
|
+
→ IntentDispatcher (controller.execute() → Page;出错走 fallback())
|
|
61
|
+
→ afterLoad chain (PostLoadContext)
|
|
62
|
+
→ render (SSR: HTML + 序列化的 PrefetchedIntents;CSR: 空壳)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
同一个 `bootstrap()` 同时在服务器和浏览器执行。SSR 把 prefetch 后的 intent 结果序列化进 HTML,浏览器再反序列化为 `PrefetchedIntents`,让首次客户端导航复用服务端结果而不重新发请求。
|
|
66
|
+
|
|
67
|
+
## 约定
|
|
68
|
+
|
|
69
|
+
- **代码块** 直接可跑,除非注释说明不能。
|
|
70
|
+
- **文件路径** 相对项目根目录(也就是 `vite.config.ts` 所在目录)。
|
|
71
|
+
- **`vp`** 是 [Vite+](https://github.com/voidzero-dev/setup-vp) 的 CLI。用它替代 `pnpm` / `npm` / `vitest` / `tsdown`。
|
|
72
|
+
- **`@finesoft/front`** 是应用代码唯一的导入面。内部包(`core`、`browser`、`ssr`、`server`)打包在内,不对外发布。
|