@h-ai/cli 0.1.0-alpha.53 → 0.1.0-alpha.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -0
- package/dist/{chunk-U4PA632G.js → chunk-FVDXJYMO.js} +34 -67
- package/dist/chunk-FVDXJYMO.js.map +1 -0
- package/dist/cli-config-templates-WGN2I23J.js +5 -0
- package/dist/{cli-config-templates-GSXKLGU5.js.map → cli-config-templates-WGN2I23J.js.map} +1 -1
- package/dist/index.js +174 -105
- package/dist/index.js.map +1 -1
- package/dist/{src-QNEJJ3BV.js → src-6ZBWDBUB.js} +127 -87
- package/dist/{src-QNEJJ3BV.js.map → src-6ZBWDBUB.js.map} +1 -1
- package/package.json +13 -4
- package/templates/apps/api/apps/{{projectName}}-contract/package.json.hbs +1 -0
- package/templates/apps/api/apps/{{projectName}}-service/package.json.hbs +1 -0
- package/templates/apps/api/package.json.hbs +1 -0
- package/templates/apps/fullstack/apps/{{projectName}}-app/package.json.hbs +1 -0
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/package.json.hbs +1 -0
- package/templates/apps/fullstack/apps/{{projectName}}-web/package.json.hbs +1 -0
- package/templates/apps/fullstack/package.json.hbs +1 -0
- package/templates/apps/fullstack/packages/{{projectName}}-contract/package.json.hbs +1 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/package.json.hbs +1 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/package.json.hbs +1 -0
- package/templates/base/eslint.config.js.hbs +1 -0
- package/templates/base/package.json.hbs +3 -1
- package/templates/skills/AGENTS.md +8 -22
- package/templates/skills/CLAUDE.md +3 -16
- package/templates/skills/bridges/admin/AGENTS.md +3 -16
- package/templates/skills/bridges/admin/CLAUDE.md +3 -11
- package/templates/skills/bridges/admin/copilot-instructions.md +2 -39
- package/templates/skills/bridges/api/AGENTS.md +5 -18
- package/templates/skills/bridges/api/CLAUDE.md +3 -11
- package/templates/skills/bridges/api/copilot-instructions.md +3 -38
- package/templates/skills/bridges/fullstack/AGENTS.md.hbs +24 -59
- package/templates/skills/bridges/fullstack/CLAUDE.md.hbs +1 -15
- package/templates/skills/bridges/fullstack/copilot-instructions.md.hbs +5 -45
- package/templates/skills/bridges/h5/AGENTS.md +4 -17
- package/templates/skills/bridges/h5/CLAUDE.md +3 -11
- package/templates/skills/bridges/h5/copilot-instructions.md +2 -37
- package/templates/skills/bridges/mobile-app/AGENTS.md +5 -18
- package/templates/skills/bridges/mobile-app/CLAUDE.md +3 -11
- package/templates/skills/bridges/mobile-app/copilot-instructions.md +2 -38
- package/templates/skills/bridges/website/AGENTS.md +4 -17
- package/templates/skills/bridges/website/CLAUDE.md +3 -11
- package/templates/skills/bridges/website/copilot-instructions.md +2 -37
- package/templates/skills/copilot-instructions.md +2 -36
- package/templates/skills/hai-ai/SKILL.md +35 -389
- package/templates/skills/hai-ai/guide.md +383 -0
- package/templates/skills/hai-ai/reference.md +6 -2
- package/templates/skills/hai-api-client/SKILL.md +6 -6
- package/templates/skills/hai-api-contract/SKILL.md +10 -25
- package/templates/skills/hai-app-create/SKILL.md +29 -388
- package/templates/skills/hai-app-create/reference.md +350 -0
- package/templates/skills/hai-app-review/SKILL.md +15 -23
- package/templates/skills/hai-app-tests/SKILL.md +29 -494
- package/templates/skills/hai-app-tests/reference.md +442 -0
- package/templates/skills/hai-audit/SKILL.md +6 -6
- package/templates/skills/hai-build/SKILL.md +19 -330
- package/templates/skills/hai-build/reference.md +326 -0
- package/templates/skills/hai-cache/SKILL.md +28 -31
- package/templates/skills/hai-capacitor/SKILL.md +19 -364
- package/templates/skills/hai-capacitor/reference.md +352 -0
- package/templates/skills/hai-ci/SKILL.md +17 -20
- package/templates/skills/hai-core/SKILL.md +25 -564
- package/templates/skills/hai-core/reference.md +533 -0
- package/templates/skills/hai-crypto/SKILL.md +8 -20
- package/templates/skills/hai-datapipe/SKILL.md +9 -23
- package/templates/skills/hai-deploy/SKILL.md +18 -24
- package/templates/skills/hai-framework-sync/SKILL.md +8 -20
- package/templates/skills/hai-iam/SKILL.md +27 -365
- package/templates/skills/hai-iam/reference.md +343 -0
- package/templates/skills/hai-kit/SKILL.md +6 -6
- package/templates/skills/hai-payment/SKILL.md +6 -26
- package/templates/skills/hai-pr-review/SKILL.md +6 -22
- package/templates/skills/hai-reach/SKILL.md +19 -317
- package/templates/skills/hai-reach/reference.md +303 -0
- package/templates/skills/hai-reldb/SKILL.md +19 -472
- package/templates/skills/hai-reldb/reference.md +452 -0
- package/templates/skills/hai-scheduler/SKILL.md +18 -6
- package/templates/skills/hai-serv/SKILL.md +27 -759
- package/templates/skills/hai-serv/reference.md +736 -0
- package/templates/skills/hai-storage/SKILL.md +7 -25
- package/templates/skills/hai-ui/SKILL.md +29 -621
- package/templates/skills/hai-ui/reference.md +598 -0
- package/templates/skills/hai-vecdb/SKILL.md +18 -303
- package/templates/skills/hai-vecdb/reference.md +291 -0
- package/dist/chunk-U4PA632G.js.map +0 -1
- package/dist/cli-config-templates-GSXKLGU5.js +0 -5
|
@@ -0,0 +1,736 @@
|
|
|
1
|
+
# hai-serv — 详细用法
|
|
2
|
+
|
|
3
|
+
从 [SKILL.md](SKILL.md) 选择主题后读取对应小节。代码片段展示 API 用法;省略的业务变量、schema、依赖与 return 上下文需由应用补齐。
|
|
4
|
+
|
|
5
|
+
> `@h-ai/serv` 是 hai-framework 的 API Service 运行时,对外暴露最小 HTTP App 抽象,将 `@h-ai/api-contract` 的领域 contract 挂载成跨端可访问的 HTTP API;Hono 是内部实现细节,应用代码不要直接 import 或暴露 Hono。
|
|
6
|
+
|
|
7
|
+
## 运行环境
|
|
8
|
+
|
|
9
|
+
> ⚠️ **服务端模块(Node.js / Fetch Runtime)。** 浏览器端通过 `@h-ai/api-client` 调用由本模块暴露的 HTTP API。
|
|
10
|
+
|
|
11
|
+
> 生命周期说明:`@h-ai/serv` 是无状态 HTTP App 装配器,不提供 `init()` / `close()`;依赖模块(iam/storage/ai/crypto 等)由应用先初始化,并在 `serv.listen(..., { onClose })` 中反向释放。`serv.createApp()` 的启动期配置错误会 fail-fast 抛出。
|
|
12
|
+
|
|
13
|
+
## 适用场景
|
|
14
|
+
|
|
15
|
+
- 将 `@h-ai/api-contract` contract 装配成最小 HTTP App 抽象
|
|
16
|
+
- 配置 `/health`、`/ready`、`/openapi.json`、`/docs` 等系统端点
|
|
17
|
+
- 在 procedure route 上添加 `.auth()`、`.permission()` 或 `.role()` guard
|
|
18
|
+
- 切换 Node.js (`@hono/node-server`) 或 Fetch Runtime(Cloudflare Workers / Deno)部署
|
|
19
|
+
- 自定义 `ServContext`(注入 session、tenant 等请求级数据)
|
|
20
|
+
- 生成 OpenAPI 3.1 文档供外部工具消费
|
|
21
|
+
- 通过 `config/_serv.yml` 管理 API 前缀、OpenAPI、docs、health、rpc 等 HTTP 挂载配置
|
|
22
|
+
- 通过 `serv.createRuntimeSecurityPolicy(...)` 对生产 CORS Origin、原生 WebView Origin、Secure Cookie 与 API 文档暴露执行 fail-closed 策略
|
|
23
|
+
- 启用传输加密:`serv.createApp({ transport: { crypto } })`
|
|
24
|
+
- 高级场景通过 `createApp({ middlewares })` 自定义 HTTP middleware,通过 `createContext` 注入请求级业务字段
|
|
25
|
+
- 公开只读文件通过 `serv.storageAssets(...)` 统一处理安全 key、MIME 白名单、GET/HEAD、ETag 与缓存头
|
|
26
|
+
- 为 Audio WebSocket 配置一次性 ticket 校验、IAM/模型/配额授权与并发释放钩子
|
|
27
|
+
|
|
28
|
+
## 使用步骤
|
|
29
|
+
|
|
30
|
+
### 1. 安装依赖
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
// @h-ai/serv 依赖 @h-ai/api-contract 提供 contract 定义
|
|
34
|
+
// 业务 procedure 依赖对应领域模块(iam/storage/ai)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### 2. 创建并启动 app
|
|
38
|
+
|
|
39
|
+
```typescript
|
|
40
|
+
import { ai } from '@h-ai/ai'
|
|
41
|
+
import { apiContract } from '@h-ai/api-contract'
|
|
42
|
+
import { iam } from '@h-ai/iam'
|
|
43
|
+
import { serv } from '@h-ai/serv'
|
|
44
|
+
import { createAiProcedures } from '@h-ai/serv/features/ai'
|
|
45
|
+
import { createIamProcedures } from '@h-ai/serv/features/iam'
|
|
46
|
+
import { createStorageProcedures } from '@h-ai/serv/features/storage'
|
|
47
|
+
import { storage } from '@h-ai/storage'
|
|
48
|
+
|
|
49
|
+
const contract = apiContract.create({ iam: apiContract.iam, storage: apiContract.storage, ai: apiContract.ai })
|
|
50
|
+
|
|
51
|
+
// 装配 procedures(每个 feature 对应 contract 中的一个领域)
|
|
52
|
+
const procedures = {
|
|
53
|
+
iam: createIamProcedures({ iam }),
|
|
54
|
+
storage: createStorageProcedures({ storage }),
|
|
55
|
+
ai: createAiProcedures({ ai }),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 创建 HTTP App 抽象;不要在应用导出类型里暴露 Hono
|
|
59
|
+
const app = serv.createApp({
|
|
60
|
+
contract,
|
|
61
|
+
procedures,
|
|
62
|
+
http: {
|
|
63
|
+
apiPrefix: '/api/v1',
|
|
64
|
+
openapi: { path: '/openapi.json' },
|
|
65
|
+
docs: { path: '/docs' },
|
|
66
|
+
health: { path: '/health', readyPath: '/ready' },
|
|
67
|
+
},
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
// Node.js 启动(host/port 来自 _serv.yml 的 server;HOST/PORT 环境变量高于配置文件;onClose 自动监听 SIGINT/SIGTERM)
|
|
71
|
+
const { server } = core.config.getOrThrow<ServConfig>('serv')
|
|
72
|
+
serv.listen(app, {
|
|
73
|
+
host: server.host,
|
|
74
|
+
port: server.port,
|
|
75
|
+
onListening: info => logger.info('API service listening', { port: info.port }),
|
|
76
|
+
onClose: closeApp, // 业务模块关闭函数(ai/storage/iam/reldb 等反向释放)
|
|
77
|
+
})
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
生产部署先从 `_core.yml.env` 与 `_serv.yml.cors` 创建安全策略;不要在业务应用重复实现 Origin 解析:
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
const security = serv.createRuntimeSecurityPolicy({
|
|
84
|
+
environment: coreConfig.env,
|
|
85
|
+
corsOrigin: servConfig.cors.origin,
|
|
86
|
+
nativeOrigins: servConfig.cors.nativeOrigins,
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
const app = serv.createApp({
|
|
90
|
+
contract,
|
|
91
|
+
procedures,
|
|
92
|
+
http: {
|
|
93
|
+
...servConfig.http,
|
|
94
|
+
openapi: security.exposeApiDocs ? servConfig.http.openapi : false,
|
|
95
|
+
docs: security.exposeApiDocs ? servConfig.http.docs : false,
|
|
96
|
+
},
|
|
97
|
+
refreshCookie: { secure: security.secureRefreshCookie },
|
|
98
|
+
middlewares: [{
|
|
99
|
+
middleware: serv.cors({
|
|
100
|
+
origin: security.allowOrigin,
|
|
101
|
+
credentials: servConfig.cors.credentials,
|
|
102
|
+
allowedHeaders: [...servConfig.cors.allowedHeaders],
|
|
103
|
+
exposedHeaders: [...servConfig.cors.exposedHeaders],
|
|
104
|
+
}),
|
|
105
|
+
}],
|
|
106
|
+
})
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
- 生产环境 `cors.origin` 禁止为空或 `*`,启动时 fail-fast。
|
|
110
|
+
- 普通 Web Origin 仅接受 HTTP(S);原生 Origin 通过 `cors.nativeOrigins` 显式允许 HTTP(S)、Capacitor 或 Tauri。
|
|
111
|
+
- Origin 按完整规范化值精确匹配,不使用前缀/后缀匹配。
|
|
112
|
+
- 生产环境自动要求 Secure refresh cookie,并把 OpenAPI/docs 作为安全上限关闭。
|
|
113
|
+
|
|
114
|
+
### 2.1 Audio WebSocket 安全接入
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
const app = serv.createApp({
|
|
118
|
+
contract,
|
|
119
|
+
procedures,
|
|
120
|
+
iam,
|
|
121
|
+
audio: {
|
|
122
|
+
ai,
|
|
123
|
+
verifyTicket: consumeAudioTicket, // 校验用途/时效并原子消费,返回 { session, grant? }(推荐 iam.ticket.consume)
|
|
124
|
+
authorize: (session, request, grant) => authorizeAudioRequest(session, request, grant),
|
|
125
|
+
onSessionEnd: (session, request) => releaseAudioConcurrency(session.userId, request.operation),
|
|
126
|
+
},
|
|
127
|
+
})
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
- ticket 由已登录 HTTP 请求签发(推荐 `iam.ticket.issue`),短期有效且只能消费一次;连接建立即鉴权(独立预鉴权超时,默认 5s),未鉴权不处理任何业务帧;普通 IAM access token 禁止进入 WebSocket URL。
|
|
131
|
+
- `verifyTicket` 返回 `HaiResult<AudioTicketVerification>`(`{ session, grant? }`);`grant` 承载票据绑定的操作/模型/会话,供 `authorize` 交叉校验 `start` 未越权。
|
|
132
|
+
- 每帧经运行时 Zod 校验并按严格状态机接受(识别只收音频、合成只收文本、`start`/`done` 各一次、`segmentId` 会话内唯一);输入队列、消息积压、发送缓冲均有上限(`preAuthTimeoutMs`/`maxPendingMessages`/`maxSendBufferBytes` 等可调),超限或取消时以领域错误关闭并级联中止上游。
|
|
133
|
+
- `authorize` 返回的 `AuthorizedAudioRequest` 是唯一会传给 `ai.audio` 的模型、音色与格式配置;未提供时客户端的付费参数会被忽略(若票据 `grant.model` 存在则采用之)。
|
|
134
|
+
- IAM 权限、Persona、套餐配额和并发计数属于应用策略;框架提供校验与结束钩子,不硬编码业务权限名或存储。
|
|
135
|
+
|
|
136
|
+
### 3. 扩展应用自有 contract + procedures
|
|
137
|
+
|
|
138
|
+
`apiContract.create({...})` 接受任意 key——除了框架提供的 `iam/storage/ai`,应用可以挂入自己的领域 contract,
|
|
139
|
+
客户端通过 `client.<key>.<procedure>(...)` 调用,类型完全推导自同一份 contract。
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
// apps/api-service-contract/src/app-contract.ts
|
|
143
|
+
import { apiContract } from '@h-ai/api-contract'
|
|
144
|
+
import { z } from 'zod'
|
|
145
|
+
|
|
146
|
+
const InfoOutput = apiContract.haiResultSchema(z.object({ name: z.string(), version: z.string() }))
|
|
147
|
+
const EchoInput = z.object({ message: z.string().min(1).max(2000) })
|
|
148
|
+
const EchoOutput = apiContract.haiResultSchema(z.object({ message: z.string(), userId: z.string() }))
|
|
149
|
+
|
|
150
|
+
export const appContract = {
|
|
151
|
+
info: apiContract.route({ method: 'POST', path: '/app/info', operationId: 'app.info', tags: ['app'] })
|
|
152
|
+
.output(InfoOutput),
|
|
153
|
+
echo: apiContract.route({ method: 'POST', path: '/app/echo', operationId: 'app.echo', tags: ['app'] })
|
|
154
|
+
.input(EchoInput).output(EchoOutput),
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
```typescript
|
|
159
|
+
// src/server/procedures/app-procedures.ts
|
|
160
|
+
import type { ServContext } from '@h-ai/serv'
|
|
161
|
+
import { appContract } from '@h-ai/api-service-contract'
|
|
162
|
+
import { ok } from '@h-ai/core'
|
|
163
|
+
import { serv } from '@h-ai/serv'
|
|
164
|
+
|
|
165
|
+
export function createAppProcedures(deps: { name: string; version: string }) {
|
|
166
|
+
return serv
|
|
167
|
+
.implement(appContract)
|
|
168
|
+
.context<ServContext>()
|
|
169
|
+
// 公开 procedure:route 名只出现一次,handler 直接返回 HaiResult。
|
|
170
|
+
.route('info', () => ok({ name: deps.name, version: deps.version }))
|
|
171
|
+
// 鉴权 procedure:guard 通过后 context.session 自动收窄为非空。
|
|
172
|
+
.route('echo')
|
|
173
|
+
.auth()
|
|
174
|
+
.handle(({ input, context }) => ok({
|
|
175
|
+
message: input.message,
|
|
176
|
+
userId: context.session.userId,
|
|
177
|
+
}))
|
|
178
|
+
.build()
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
把自有 contract / procedures 与框架的 iam/storage/ai 平铺合并:
|
|
183
|
+
|
|
184
|
+
```typescript
|
|
185
|
+
const contract = apiContract.create({
|
|
186
|
+
iam: apiContract.iam,
|
|
187
|
+
storage: apiContract.storage,
|
|
188
|
+
ai: apiContract.ai,
|
|
189
|
+
app: appContract, // ← 自有契约
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
const procedures = {
|
|
193
|
+
iam: createIamProcedures({ iam }),
|
|
194
|
+
storage: createStorageProcedures({ storage }),
|
|
195
|
+
ai: createAiProcedures({ ai }),
|
|
196
|
+
app: createAppProcedures({ name: 'demo', version: '1.0.0' }),
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const app = serv.createApp({ contract, procedures, http, iam })
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
注意要点:
|
|
203
|
+
|
|
204
|
+
- **无 input 的 POST 过程**:默认不发送 body;transport 加密层会自动跳过空 body 请求,客户端可直接 `client.app.info()`。
|
|
205
|
+
- **认证授权写在 route 链上**:公开过程使用 `.route(path, handler)`;需要认证时使用 `.route(path).auth().handle(handler)`。
|
|
206
|
+
- **客户端导入相同 contract**:把服务端与客户端共享的 contract 独立成 package(如 `@h-ai/api-service-contract`),禁止从 app 源码目录跨应用 import。
|
|
207
|
+
|
|
208
|
+
### 5. Fetch Runtime(Cloudflare Workers / Deno / Bun)
|
|
209
|
+
|
|
210
|
+
```typescript
|
|
211
|
+
import { serv } from '@h-ai/serv'
|
|
212
|
+
|
|
213
|
+
// toFetch 返回标准 Fetch handler
|
|
214
|
+
const handler = serv.toFetch(app)
|
|
215
|
+
|
|
216
|
+
export default { fetch: handler }
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### 6. 通过 `config/_serv.yml` 管理 HTTP 配置
|
|
220
|
+
|
|
221
|
+
`@h-ai/serv` 不自己扫描 YAML;推荐由 `@h-ai/core` 统一加载,再用 `ServConfigSchema` 校验:
|
|
222
|
+
|
|
223
|
+
```typescript
|
|
224
|
+
import { core } from '@h-ai/core'
|
|
225
|
+
import { serv, ServConfigSchema } from '@h-ai/serv'
|
|
226
|
+
|
|
227
|
+
core.init({ configDir: './config' })
|
|
228
|
+
|
|
229
|
+
const validation = core.config.validate('serv', ServConfigSchema)
|
|
230
|
+
if (!validation.success)
|
|
231
|
+
throw new Error(validation.error.message)
|
|
232
|
+
|
|
233
|
+
const servConfig = core.config.getOrThrow<import('@h-ai/serv').ServConfig>('serv')
|
|
234
|
+
|
|
235
|
+
const app = serv.createApp({
|
|
236
|
+
contract,
|
|
237
|
+
procedures,
|
|
238
|
+
http: servConfig.http,
|
|
239
|
+
transport: servConfig.transport === false
|
|
240
|
+
? undefined
|
|
241
|
+
: {
|
|
242
|
+
crypto,
|
|
243
|
+
keyExchangePath: servConfig.transport.keyExchangePath,
|
|
244
|
+
excludePaths: [...servConfig.transport.excludePaths],
|
|
245
|
+
maxClients: servConfig.transport.maxClients,
|
|
246
|
+
},
|
|
247
|
+
})
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
```yaml
|
|
251
|
+
# config/_serv.yml
|
|
252
|
+
server:
|
|
253
|
+
host: 127.0.0.1
|
|
254
|
+
port: 3000
|
|
255
|
+
|
|
256
|
+
http:
|
|
257
|
+
apiPrefix: /api/v1
|
|
258
|
+
openapi:
|
|
259
|
+
path: /openapi.json
|
|
260
|
+
docs:
|
|
261
|
+
path: /docs
|
|
262
|
+
health:
|
|
263
|
+
path: /health
|
|
264
|
+
readyPath: /ready
|
|
265
|
+
rpc: false
|
|
266
|
+
|
|
267
|
+
transport:
|
|
268
|
+
keyExchangePath: /_hai/key-exchange
|
|
269
|
+
excludePaths:
|
|
270
|
+
- /health
|
|
271
|
+
- /ready
|
|
272
|
+
- /openapi.json
|
|
273
|
+
- /docs
|
|
274
|
+
- /_hai/scalar.js
|
|
275
|
+
maxClients: 10000
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
## 核心 API
|
|
279
|
+
|
|
280
|
+
### `serv.createApp(options)` — 创建 HTTP App 抽象
|
|
281
|
+
|
|
282
|
+
```typescript
|
|
283
|
+
import type { CreateServAppOptions } from '@h-ai/serv'
|
|
284
|
+
|
|
285
|
+
export function createServerApp() {
|
|
286
|
+
return serv.createApp({
|
|
287
|
+
contract, // AnyContractRouter — 通过 apiContract.create() 组合的 contract
|
|
288
|
+
procedures, // Router<AnyContractRouter, ServContext> — procedure 实现
|
|
289
|
+
http?, // ServHttpConfigInput — HTTP 端点配置(见下方配置节)
|
|
290
|
+
middlewares?, // readonly ServMiddlewareMount[] — 自定义 HTTP middleware(日志/CORS/限流/租户头校验)
|
|
291
|
+
iam?, // ServIam — 顶层 IAM 句柄,同时驱动 access token 校验与 refresh cookie【推荐】
|
|
292
|
+
refreshCookie?, // RefreshCookieConfig — 浏览器 cookie / 受信原生 body 的 refresh token 通道
|
|
293
|
+
transport?, // { crypto, keyExchangePath?, excludePaths?, maxClients? } — 统一传输加密
|
|
294
|
+
verifyToken?, // (token) => Promise<HaiResult<ServSession>> — 逃脱口:不使用 iam 时提供自定义校验
|
|
295
|
+
createContext?, // CreateServContext — 高级:完全接管上下文构造(设置后 serv 不再自动填充 session)
|
|
296
|
+
})
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
### `apiContract.route` / `serv.implement` — contract 与运行时分层
|
|
301
|
+
|
|
302
|
+
应用代码不要直接 `import { oc } from '@orpc/contract'` 或 `import { implement } from '@orpc/server'`。
|
|
303
|
+
contract 路由统一用 `@h-ai/api-contract` 定义,procedure 运行时统一用 `@h-ai/serv` 实现:
|
|
304
|
+
|
|
305
|
+
```typescript
|
|
306
|
+
import { apiContract } from '@h-ai/api-contract'
|
|
307
|
+
import { serv } from '@h-ai/serv'
|
|
308
|
+
|
|
309
|
+
// 等价于 oc.route(...),但不让应用感知 @orpc/contract
|
|
310
|
+
const route = apiContract.route({ method: 'POST', path: '/x', operationId: 'x', tags: ['x'] })
|
|
311
|
+
|
|
312
|
+
const procedures = serv
|
|
313
|
+
.implement(appContract)
|
|
314
|
+
.context<ServContext>()
|
|
315
|
+
.route('info', infoHandler)
|
|
316
|
+
.route('echo')
|
|
317
|
+
.auth()
|
|
318
|
+
.handle(echoHandler)
|
|
319
|
+
.build()
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
### 传输加密
|
|
323
|
+
|
|
324
|
+
serv 不暴露本地传输加密工厂;内部统一调用 `crypto.transport.createServer()`。客户端用 `@h-ai/api-client` 的 `transport` 配置自动协商。
|
|
325
|
+
|
|
326
|
+
```typescript
|
|
327
|
+
import { crypto } from '@h-ai/crypto'
|
|
328
|
+
import { serv } from '@h-ai/serv'
|
|
329
|
+
|
|
330
|
+
await crypto.init()
|
|
331
|
+
|
|
332
|
+
const app = serv.createApp({
|
|
333
|
+
contract,
|
|
334
|
+
procedures,
|
|
335
|
+
http: { apiPrefix: '/api/v1' },
|
|
336
|
+
transport: {
|
|
337
|
+
crypto,
|
|
338
|
+
// keyExchangePath 默认 '/_hai/key-exchange'
|
|
339
|
+
// maxClients: 10000,
|
|
340
|
+
// keyStore: createRedisTransportKeyStore({ cache, ttlSeconds: 3600 }),
|
|
341
|
+
},
|
|
342
|
+
})
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
客户端:
|
|
346
|
+
|
|
347
|
+
```typescript
|
|
348
|
+
await apiClient.init({
|
|
349
|
+
baseUrl: 'https://api.example.com/api/v1',
|
|
350
|
+
transport: { crypto },
|
|
351
|
+
})
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
若服务端通过 `config/_serv.yml` 自定义了 `transport.keyExchangePath`,客户端也必须传入同一路径:
|
|
355
|
+
|
|
356
|
+
```typescript
|
|
357
|
+
await apiClient.init({
|
|
358
|
+
baseUrl: 'https://api.example.com/api/v1',
|
|
359
|
+
transport: {
|
|
360
|
+
crypto,
|
|
361
|
+
keyExchangePath: '/custom/key-exchange',
|
|
362
|
+
},
|
|
363
|
+
})
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
> 不要导入或暴露子目录内部 transport 工厂;公共装配点只有 `serv.createApp({ transport: { crypto } })`。
|
|
367
|
+
|
|
368
|
+
多节点部署时,直接在 `serv.createApp({ transport })` 的运行时对象里注入 `keyStore` 即可;推荐从 `@h-ai/crypto` 根入口导入 `createRedisTransportKeyStore()` / `createReldbTransportKeyStore()`。`keyStore` 不属于 `_serv.yml` 配置项,配置文件仍只保留静态路径和白名单。
|
|
369
|
+
|
|
370
|
+
**上下文工厂优先级**(`context.session` 填充来源):
|
|
371
|
+
|
|
372
|
+
1. 显式 `createContext`(高级场景:多租户、额外字段)
|
|
373
|
+
2. `verifyToken`(逃脱口:使用非 @h-ai/iam 的认证服务)
|
|
374
|
+
3. `iam.session.verifyToken`(推荐:传入 `iam` 后自动启用)
|
|
375
|
+
4. 默认 `parseRequestContext`:仅解析请求元数据,`session` 为 `undefined`
|
|
376
|
+
|
|
377
|
+
> ⚠️ `verifyToken` 会在每次请求调用(不缓存);verifyToken 失败或抛错统一收敛为 `session=undefined`,由 route 的 `.auth()` / `.permission()` / `.role()` 统一返回 401。
|
|
378
|
+
|
|
379
|
+
### 链式 procedure guard
|
|
380
|
+
|
|
381
|
+
| 链式调用 | 说明 |
|
|
382
|
+
| --- | --- |
|
|
383
|
+
| `.route(path, handler)` | 注册公开 procedure;path/input/output 从 contract 推导 |
|
|
384
|
+
| `.route(path).auth().handle(handler)` | 要求 session;通过后 `context.session` 自动收窄 |
|
|
385
|
+
| `.permission(permission)` | 隐含认证并检查权限,支持 `serv.WILDCARD_PERMISSION` |
|
|
386
|
+
| `.role(role)` | 隐含认证并检查角色,支持 `serv.WILDCARD_ROLE` |
|
|
387
|
+
| `.build()` | contract 全部 procedures 实现后构建 router |
|
|
388
|
+
| `serv.validateInputOrFail(zodSchema, input, locale)` | 在 procedure 内执行 Zod 二次校验,失败时返回本地化 `HaiResult` + `ValidationFormError[]` |
|
|
389
|
+
| `serv.resolveRequestLocale(headers)` | 从 `x-hai-locale` / `Accept-Language` 解析并规范化 locale |
|
|
390
|
+
| `serv.m(key, { locale, params })` | 读取 serv 自身 i18n 消息(请求级本地化) |
|
|
391
|
+
|
|
392
|
+
```typescript
|
|
393
|
+
import { serv } from '@h-ai/serv'
|
|
394
|
+
|
|
395
|
+
const procedures = serv
|
|
396
|
+
.implement(appContract)
|
|
397
|
+
.context<ServContext>()
|
|
398
|
+
.route('info', infoHandler)
|
|
399
|
+
.route('users.update')
|
|
400
|
+
.permission('user.write')
|
|
401
|
+
.role('admin')
|
|
402
|
+
.handle(updateUserHandler)
|
|
403
|
+
.build()
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
- `.permission()` / `.role()` 隐含 `.auth()`;未登录返回 401,授权不足返回 403。
|
|
407
|
+
- 同一路由声明多个 permission/role 时必须全部满足。
|
|
408
|
+
- handler 的未处理异常由 router 统一转换成 INTERNAL_ERROR HaiResult。
|
|
409
|
+
- 重复/未知路径会被拒绝;遗漏 contract procedure 时 `.build()` 在类型上不可用,运行时也会校验。
|
|
410
|
+
|
|
411
|
+
### 高级:自定义 pipeline(HTTP middleware + context + route guard)
|
|
412
|
+
|
|
413
|
+
`@h-ai/serv` 的 pipeline 分三层:
|
|
414
|
+
|
|
415
|
+
1. **HTTP middleware 层**:通过 `serv.createApp({ middlewares })` 注入 HTTP middleware
|
|
416
|
+
2. **context 层**:通过 `verifyToken` / `createContext` / `serv.buildAuthContextFactory()` 自定义请求上下文
|
|
417
|
+
3. **route guard 层**:通过 `.auth()` / `.permission()` / `.role()` 声明认证授权
|
|
418
|
+
|
|
419
|
+
#### HTTP middleware
|
|
420
|
+
|
|
421
|
+
适用于请求日志、trace、限流、CORS、租户头校验等 **HTTP 层** 横切逻辑,也可挂载不适合进入 JSON/oRPC contract 的 WebSocket、文件和二进制端点。常见 CORS 场景可直接复用 `serv.cors(...)`:
|
|
422
|
+
|
|
423
|
+
```typescript
|
|
424
|
+
import type { ServMiddleware } from '@h-ai/serv'
|
|
425
|
+
import { serv } from '@h-ai/serv'
|
|
426
|
+
|
|
427
|
+
const requestMetrics: ServMiddleware = async (c, next) => {
|
|
428
|
+
const startedAt = Date.now()
|
|
429
|
+
await next()
|
|
430
|
+
c.header('x-response-time-ms', String(Date.now() - startedAt))
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const app = serv.createApp({
|
|
434
|
+
contract,
|
|
435
|
+
procedures,
|
|
436
|
+
http: { apiPrefix: '/api/v1' },
|
|
437
|
+
middlewares: [
|
|
438
|
+
{
|
|
439
|
+
middleware: serv.cors({
|
|
440
|
+
origin: origin => origin === 'https://app.example.com',
|
|
441
|
+
credentials: true,
|
|
442
|
+
exposedHeaders: ['X-Encrypted', 'X-Request-Id'],
|
|
443
|
+
}),
|
|
444
|
+
},
|
|
445
|
+
{ middleware: requestMetrics },
|
|
446
|
+
{
|
|
447
|
+
path: '/api/v1/*',
|
|
448
|
+
middleware: async (c, next) => {
|
|
449
|
+
if (!c.req.header('x-tenant-id'))
|
|
450
|
+
return c.text('Missing x-tenant-id', 400)
|
|
451
|
+
await next()
|
|
452
|
+
},
|
|
453
|
+
},
|
|
454
|
+
{
|
|
455
|
+
path: '/assets/*',
|
|
456
|
+
middleware: serv.storageAssets({
|
|
457
|
+
storage,
|
|
458
|
+
pathPrefix: '/assets/',
|
|
459
|
+
keyPattern: /^public\/[\w-]+\.png$/,
|
|
460
|
+
allowedContentTypes: ['image/png'],
|
|
461
|
+
cacheControl: 'public, max-age=31536000, immutable',
|
|
462
|
+
crossOriginResourcePolicy: 'cross-origin',
|
|
463
|
+
}),
|
|
464
|
+
},
|
|
465
|
+
],
|
|
466
|
+
})
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
执行顺序固定为:`securityHeaders` → `middlewares` → `transport`(若启用)→ health/refresh-cookie/OpenAPI/RPC/docs/oRPC routes。
|
|
470
|
+
|
|
471
|
+
- `middlewares` 先于 `transport` 执行,因此 CORS preflight 或二进制端点可以直接短路;短路响应仍带内置安全头,但不会经过 transport。
|
|
472
|
+
- 公开只读文件优先使用 `serv.storageAssets(...)`;应用仍须显式提供 key/MIME 白名单、缓存与跨域策略,框架不内置头像等业务概念。
|
|
473
|
+
- 私有资源、Range 下载或动态授权继续使用自定义 middleware/procedure。
|
|
474
|
+
- 若需要读取解密后的业务 body,请改用 context / procedure 层扩展。
|
|
475
|
+
- 浏览器若需读取自定义响应头(例如 transport 的 `X-Encrypted`),记得通过 `serv.cors({ exposedHeaders: [...] })` 显式暴露。
|
|
476
|
+
|
|
477
|
+
#### Context pipeline
|
|
478
|
+
|
|
479
|
+
如果想复用“Bearer token → session”这段逻辑,再追加租户/工作区等字段,优先:
|
|
480
|
+
|
|
481
|
+
```typescript
|
|
482
|
+
const baseContext = serv.buildAuthContextFactory(token => iam.session.verifyToken(token))
|
|
483
|
+
|
|
484
|
+
const app = serv.createApp({
|
|
485
|
+
contract,
|
|
486
|
+
procedures,
|
|
487
|
+
createContext: async ({ request }) => {
|
|
488
|
+
const context = await baseContext({ request })
|
|
489
|
+
return {
|
|
490
|
+
...context,
|
|
491
|
+
tenantId: request.headers.get('x-tenant-id') ?? null,
|
|
492
|
+
}
|
|
493
|
+
},
|
|
494
|
+
})
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
选择建议:
|
|
498
|
+
|
|
499
|
+
- 只换 token 校验实现:`verifyToken`
|
|
500
|
+
- 在默认认证上下文之上追加字段:`buildAuthContextFactory(...) + createContext`
|
|
501
|
+
- 完全接管上下文构造:`createContext`
|
|
502
|
+
|
|
503
|
+
#### Route guard
|
|
504
|
+
|
|
505
|
+
认证、权限与角色约束直接写在目标 route 上,不嵌套高阶 handler:
|
|
506
|
+
|
|
507
|
+
```typescript
|
|
508
|
+
const procedures = serv
|
|
509
|
+
.implement(widgetContract)
|
|
510
|
+
.context<ServContext>()
|
|
511
|
+
.route('widgets.list', ({ input }) => widgetService.list(input))
|
|
512
|
+
.route('widgets.create')
|
|
513
|
+
.permission('widgets.write')
|
|
514
|
+
.handle(({ input, context }) =>
|
|
515
|
+
widgetService.create(context.session.userId, input))
|
|
516
|
+
.build()
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
HTTP 审计、限流、CORS、租户头校验等跨 route 能力继续放在
|
|
520
|
+
`createApp({ middlewares })`;请求级业务字段通过 `createContext` 注入。
|
|
521
|
+
不要为认证授权重新嵌套多层高阶 handler。
|
|
522
|
+
|
|
523
|
+
### 正常业务请求中的错误与 i18n
|
|
524
|
+
|
|
525
|
+
按三层处理:
|
|
526
|
+
|
|
527
|
+
1. **oRPC contract 输入校验**:进入 handler 前自动发生;`serv.createApp()` 会把默认英文 Zod 错误改写为本地化 `errors[]`。
|
|
528
|
+
2. **handler 内二次校验**:对跨字段规则、数据库回读对象等适合 Zod 表达的场景,使用 `serv.validateInputOrFail(zodSchema, input, context.locale)`;简单业务规则直接 `err(...)` 即可。
|
|
529
|
+
3. **业务/领域错误**:不要 throw 预期失败;直接返回 `err(...)`,并在**创建错误消息的那一层**用对应模块的 i18n getter 按 `context.locale` 出消息。
|
|
530
|
+
|
|
531
|
+
```typescript
|
|
532
|
+
import { z } from 'zod'
|
|
533
|
+
import { serv } from '@h-ai/serv'
|
|
534
|
+
|
|
535
|
+
const Schema = z.object({ title: z.string().min(1) })
|
|
536
|
+
|
|
537
|
+
const procedures = serv
|
|
538
|
+
.implement(widgetContract)
|
|
539
|
+
.context<ServContext>()
|
|
540
|
+
.route('widgets.create')
|
|
541
|
+
.auth()
|
|
542
|
+
.handle(({ input, context }) => {
|
|
543
|
+
const validated = serv.validateInputOrFail(Schema, input, context.locale)
|
|
544
|
+
if (!validated.success)
|
|
545
|
+
return validated
|
|
546
|
+
|
|
547
|
+
return widgetService.create(context.session.userId, validated.data)
|
|
548
|
+
})
|
|
549
|
+
.build()
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
> 如果错误来自下游模块(如 `iam` / `storage`)并且该模块已经返回 `HaiResult`,serv 默认**透传**它的 `error.message`。由于 `HaiError` 目前不携带 `messageKey/params`,serv 边界不能对任意下游错误再做通用重翻译;要做请求级 i18n,必须在创建该错误的模块/feature 里就拿到 locale。
|
|
553
|
+
|
|
554
|
+
### `serv.parseRequestContext` — 默认 Context 解析器
|
|
555
|
+
|
|
556
|
+
从 HTTP 请求头自动解析:
|
|
557
|
+
|
|
558
|
+
| 字段 | 来源 |
|
|
559
|
+
| --- | --- |
|
|
560
|
+
| `accessToken` | `Authorization: Bearer <token>` |
|
|
561
|
+
| `requestId` | `x-request-id` 或自动生成 UUID |
|
|
562
|
+
| `ip` | `x-forwarded-for` / `x-real-ip` |
|
|
563
|
+
| `locale` | `accept-language` |
|
|
564
|
+
| `userAgent` | `user-agent` |
|
|
565
|
+
|
|
566
|
+
### `serv.generateSpec(contract, options)` — 生成 OpenAPI 文档
|
|
567
|
+
|
|
568
|
+
```typescript
|
|
569
|
+
const spec = await serv.generateSpec(contract, {
|
|
570
|
+
title: 'My API',
|
|
571
|
+
version: '1.0.0',
|
|
572
|
+
apiPrefix: '/api/v1',
|
|
573
|
+
description: '接口说明',
|
|
574
|
+
})
|
|
575
|
+
// spec 为 OpenAPI 3.1 Document 对象
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
## HTTP 配置(`ServHttpConfigInput`)
|
|
579
|
+
|
|
580
|
+
| 字段 | 默认值 | 说明 |
|
|
581
|
+
| --- | --- | --- |
|
|
582
|
+
| `apiPrefix` | `'/api/v1'` | oRPC OpenAPI handler 挂载前缀 |
|
|
583
|
+
| `health` | `{ path: '/health', readyPath: '/ready' }` | 健康与就绪检查端点 |
|
|
584
|
+
| `openapi` | `false` | OpenAPI JSON endpoint,显式开启 |
|
|
585
|
+
| `docs` | `false` | Scalar 文档页,显式开启;启用后自动挂载 `/_hai/scalar.js` 本地脚本路由 |
|
|
586
|
+
| `rpc` | `false` | 内部 RPC endpoint,显式开启;`loopback` / `private-network` 基于真实连接 IP,网关场景用 `gateway-only` |
|
|
587
|
+
| `transport` | `undefined` | 顶层配置;启用后默认挂载 `{apiPrefix}/_hai/key-exchange` |
|
|
588
|
+
|
|
589
|
+
```typescript
|
|
590
|
+
http: {
|
|
591
|
+
apiPrefix: '/api/v1',
|
|
592
|
+
openapi: { path: '/openapi.json' },
|
|
593
|
+
docs: { path: '/docs' },
|
|
594
|
+
health: { path: '/health', readyPath: '/ready' },
|
|
595
|
+
rpc: false, // 关闭内部 RPC
|
|
596
|
+
}
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
## Feature Procedures(默认实现)
|
|
600
|
+
|
|
601
|
+
`@h-ai/serv` 内置了三个 feature 模块,开箱即用:
|
|
602
|
+
|
|
603
|
+
| 导入路径 | 工厂函数 | 所需依赖 |
|
|
604
|
+
| --- | --- | --- |
|
|
605
|
+
| `@h-ai/serv/features/iam` | `createIamProcedures({ iam })` | `@h-ai/iam` |
|
|
606
|
+
| `@h-ai/serv/features/storage` | `createStorageProcedures({ storage })` | `@h-ai/storage` |
|
|
607
|
+
| `@h-ai/serv/features/ai` | `createAiProcedures({ ai })` | `@h-ai/ai` |
|
|
608
|
+
|
|
609
|
+
## 常见模式
|
|
610
|
+
|
|
611
|
+
### 自定义领域 procedure
|
|
612
|
+
|
|
613
|
+
```typescript
|
|
614
|
+
import type { ServContext } from '@h-ai/serv'
|
|
615
|
+
import { serv } from '@h-ai/serv'
|
|
616
|
+
import { myContract } from './my-contract.js'
|
|
617
|
+
|
|
618
|
+
export function createMyProcedures() {
|
|
619
|
+
return serv
|
|
620
|
+
.implement(myContract)
|
|
621
|
+
.context<ServContext>()
|
|
622
|
+
.route('widget.list')
|
|
623
|
+
.auth()
|
|
624
|
+
.handle(({ input }) => widgetService.list(input))
|
|
625
|
+
.route('widget.create')
|
|
626
|
+
.permission('widget:write')
|
|
627
|
+
.handle(({ input }) => widgetService.create(input))
|
|
628
|
+
.build()
|
|
629
|
+
}
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
### 自定义 contract 组合
|
|
633
|
+
|
|
634
|
+
```typescript
|
|
635
|
+
import { apiContract } from '@h-ai/api-contract'
|
|
636
|
+
import { widgetContract } from './widget-contract.js'
|
|
637
|
+
|
|
638
|
+
export const myAppContract = apiContract.create({
|
|
639
|
+
iam: apiContract.iam,
|
|
640
|
+
widget: widgetContract,
|
|
641
|
+
})
|
|
642
|
+
|
|
643
|
+
const app = serv.createApp({
|
|
644
|
+
contract: myAppContract,
|
|
645
|
+
procedures: {
|
|
646
|
+
iam: createIamProcedures({ iam }),
|
|
647
|
+
widget: createMyProcedures(),
|
|
648
|
+
},
|
|
649
|
+
http: { apiPrefix: '/api/v1' },
|
|
650
|
+
})
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
## 错误码
|
|
654
|
+
|
|
655
|
+
route 实现器内置以下错误:
|
|
656
|
+
|
|
657
|
+
| 错误码 | 触发条件 |
|
|
658
|
+
| --- | --- |
|
|
659
|
+
| `HaiCommonError.UNAUTHORIZED` | `.auth()`:`context.session` 为空(无 token 或 token 校验失败) |
|
|
660
|
+
| `HaiCommonError.FORBIDDEN` | `.permission()` / `.role()`:无对应权限或角色 |
|
|
661
|
+
| `HaiCommonError.INTERNAL_ERROR` | route handler 抛出未处理异常 |
|
|
662
|
+
|
|
663
|
+
## httpOnly Cookie 认证
|
|
664
|
+
|
|
665
|
+
将 refresh token 存储在 `HttpOnly` cookie 中,避免 XSS 风险(浏览器 JS 无法读取)。
|
|
666
|
+
|
|
667
|
+
先区分两个 token:
|
|
668
|
+
|
|
669
|
+
- `accessToken`:短期凭证,客户端放在内存,通过 `Authorization: Bearer <token>` 发送;
|
|
670
|
+
`serv.parseRequestContext()` / `extractBearerToken()` 解析、`buildAuthContextFactory()` 校验的就是它。
|
|
671
|
+
- `refreshToken`:长期凭证,启用 `refreshCookie` 后只保存在 httpOnly cookie;
|
|
672
|
+
它不会走 `Authorization`,也不会被 `extractBearerToken()` 读取,只会在 `/auth/refresh` 被服务端取出。
|
|
673
|
+
受信原生客户端可通过 `nativeTokenTransport` 改用 JSON body,但必须同时校验客户端标识与 Origin。
|
|
674
|
+
|
|
675
|
+
### 适用场景
|
|
676
|
+
|
|
677
|
+
- 浏览器端(Web / H5)使用 `@h-ai/api-client` 的 `apiClient.tokenStorage.httpOnlyCookie()`
|
|
678
|
+
- 对 refresh token 有更高安全要求,不希望存储在 localStorage
|
|
679
|
+
|
|
680
|
+
### 工作流程
|
|
681
|
+
|
|
682
|
+
1. 浏览器 POST `/auth/login` → serv 拦截 oRPC 成功响应 → `Set-Cookie: hai_refresh_token=...;HttpOnly;SameSite=Strict`,并从 JSON 响应体擦除 `refreshToken`
|
|
683
|
+
2. Access token 由客户端存内存(不持久化)
|
|
684
|
+
3. Access token 过期 → 客户端 POST `/auth/refresh`(浏览器自动携带 cookie)
|
|
685
|
+
4. serv 读取 cookie → 调用 `iam.session.refresh` → 返回新 access token + 更新 cookie(响应体不暴露 refresh token)
|
|
686
|
+
5. POST `/auth/logout` → serv 清除 cookie(`Max-Age=0`)
|
|
687
|
+
|
|
688
|
+
原生客户端匹配 `nativeTokenTransport.isRequest` 后,登录/注册/刷新响应保留轮换后的
|
|
689
|
+
`refreshToken`,刷新请求从 `{ refreshToken }` body 读取,且不读写浏览器 Cookie。
|
|
690
|
+
|
|
691
|
+
若同时启用 `transport: { crypto }`,`/auth/refresh` 也必须走加密链路;cookie-only 刷新请求允许空 body,服务端不要把 `Request.body === null` 误判为需要解密的请求体。
|
|
692
|
+
|
|
693
|
+
### 配置
|
|
694
|
+
|
|
695
|
+
```typescript
|
|
696
|
+
const app = serv.createApp({
|
|
697
|
+
contract,
|
|
698
|
+
procedures,
|
|
699
|
+
http: { apiPrefix: '/api/v1' },
|
|
700
|
+
iam, // 顶层句柄:同时驱动 verifyToken 与 refresh
|
|
701
|
+
refreshCookie: {
|
|
702
|
+
// cookieName?: string 默认 'hai_refresh_token'
|
|
703
|
+
// maxAge?: number 默认 30 * 24 * 3600(30 天)
|
|
704
|
+
// secure?: boolean 默认 NODE_ENV=production 时开启
|
|
705
|
+
// onRefresh?: (token) => ... 可选:覆盖默认的 iam.session.refresh
|
|
706
|
+
nativeTokenTransport: {
|
|
707
|
+
// 必须 fail-closed:同时校验平台标识与允许的原生 Origin。
|
|
708
|
+
isRequest: request =>
|
|
709
|
+
request.headers.get('x-client-platform') === 'capacitor'
|
|
710
|
+
&& security.isNativeOrigin(request.headers.get('origin') ?? ''),
|
|
711
|
+
},
|
|
712
|
+
},
|
|
713
|
+
// ✅ 顶层 iam 同时为 access token 校验提供默认实现,无需再传 verifyToken
|
|
714
|
+
})
|
|
715
|
+
```
|
|
716
|
+
|
|
717
|
+
> **逃脱口**:不使用 `@h-ai/iam` 时,可显式传入顶层 `verifyToken: token => myAuthService.verify(token)`。
|
|
718
|
+
|
|
719
|
+
### Cookie 规格
|
|
720
|
+
|
|
721
|
+
| 属性 | 值 |
|
|
722
|
+
| --- | --- |
|
|
723
|
+
| Name | `hai_refresh_token`(可配置) |
|
|
724
|
+
| Path | `{apiPrefix}/auth/refresh`(最小范围) |
|
|
725
|
+
| HttpOnly | ✓ |
|
|
726
|
+
| SameSite | `Strict` |
|
|
727
|
+
| Secure | 生产自动开启,`secure: false` 可在 HTTP 开发环境关闭 |
|
|
728
|
+
| Max-Age | 30 天(可配置) |
|
|
729
|
+
|
|
730
|
+
## 测试
|
|
731
|
+
|
|
732
|
+
```bash
|
|
733
|
+
pnpm --filter @h-ai/serv test
|
|
734
|
+
pnpm --filter @h-ai/serv typecheck
|
|
735
|
+
pnpm --filter @h-ai/serv lint
|
|
736
|
+
```
|