@h-ai/serv 0.1.0-alpha.15
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/LICENSE +202 -0
- package/README.md +515 -0
- package/dist/chunk-27PCPE37.js +17 -0
- package/dist/chunk-27PCPE37.js.map +1 -0
- package/dist/chunk-4QBF4PUG.js +128 -0
- package/dist/chunk-4QBF4PUG.js.map +1 -0
- package/dist/chunk-YQV2OMQK.js +16 -0
- package/dist/chunk-YQV2OMQK.js.map +1 -0
- package/dist/features/ai.d.ts +361 -0
- package/dist/features/ai.js +206 -0
- package/dist/features/ai.js.map +1 -0
- package/dist/features/iam.d.ts +774 -0
- package/dist/features/iam.js +145 -0
- package/dist/features/iam.js.map +1 -0
- package/dist/features/storage.d.ts +163 -0
- package/dist/features/storage.js +94 -0
- package/dist/features/storage.js.map +1 -0
- package/dist/index.d.ts +646 -0
- package/dist/index.js +809 -0
- package/dist/index.js.map +1 -0
- package/dist/serv-context-CwewduFi.d.ts +155 -0
- package/package.json +82 -0
package/README.md
ADDED
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
# @h-ai/serv
|
|
2
|
+
|
|
3
|
+
> hai-framework 的 Hono + oRPC API 服务运行时,把 `@h-ai/api-contract` 的契约挂载为跨端可访问的 HTTP API。
|
|
4
|
+
|
|
5
|
+
## 能力概览
|
|
6
|
+
|
|
7
|
+
- 一行启动:`serv.createApp(...)` → `serv.listen(app, { port })`
|
|
8
|
+
- 扁平 API(最小知识):`serv.listen / serv.toFetch / serv.requireAuth / serv.requirePermission / serv.generateSpec / ...`
|
|
9
|
+
- 自定义 pipeline:`createApp({ middlewares })` 挂 HTTP middleware;procedure wrapper 复用根入口导出的共享类型
|
|
10
|
+
- 默认 feature procedures:`createIamProcedures()`、`createStorageProcedures()`、`createAiProcedures()`
|
|
11
|
+
- 内置安全响应头、健康检查、可选 OpenAPI JSON、可选 Scalar 文档页、可选内部 RPC endpoint
|
|
12
|
+
- 可选传输加密:`serv.createApp({ transport: { crypto } })` 自动挂载密钥协商与请求/响应加解密
|
|
13
|
+
|
|
14
|
+
## 快速开始
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { apiContract } from '@h-ai/api-contract'
|
|
18
|
+
import { serv } from '@h-ai/serv'
|
|
19
|
+
import { createAiProcedures } from '@h-ai/serv/features/ai'
|
|
20
|
+
import { createIamProcedures } from '@h-ai/serv/features/iam'
|
|
21
|
+
import { createStorageProcedures } from '@h-ai/serv/features/storage'
|
|
22
|
+
|
|
23
|
+
// 1) 创建契约
|
|
24
|
+
const contract = apiContract.create({ iam: apiContract.iam, storage: apiContract.storage, ai: apiContract.ai })
|
|
25
|
+
|
|
26
|
+
// 2) 组装 procedures(注入 iam / storage / ai 已初始化的 functions)
|
|
27
|
+
const procedures = {
|
|
28
|
+
iam: createIamProcedures({ iam }),
|
|
29
|
+
storage: createStorageProcedures({ storage }),
|
|
30
|
+
ai: createAiProcedures({ ai }),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 3) 创建 app
|
|
34
|
+
const app = serv.createApp({
|
|
35
|
+
contract,
|
|
36
|
+
procedures,
|
|
37
|
+
http: {
|
|
38
|
+
apiPrefix: '/api/v1',
|
|
39
|
+
openapi: { path: '/openapi.json' },
|
|
40
|
+
docs: { path: '/docs' },
|
|
41
|
+
},
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
// 4) 启动 Node 服务(读取 PORT / HOST 环境变量,onClose 自动处理 SIGINT/SIGTERM)
|
|
45
|
+
serv.listen(app, {
|
|
46
|
+
onListening: info => logger.info('API service listening', { port: info.port }),
|
|
47
|
+
onClose: closeApp,
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
// 5) 优雅关闭由 serv 托管,无需手动注册 process.once('SIGINT', ...)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### 容器对外暴露
|
|
54
|
+
|
|
55
|
+
默认 `host` 为 `127.0.0.1`(仅本机可达)。容器或需对外提供服务时显式指定:
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
serv.listen(app, { host: '0.0.0.0', onClose: closeApp })
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Fetch Runtime(Workers / Bun / Deno)
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
const handler = serv.toFetch(app)
|
|
65
|
+
export default { fetch: handler }
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### 传输加密(与 `@h-ai/crypto` 统一)
|
|
69
|
+
|
|
70
|
+
serv 不暴露本地传输加密工厂;统一从 `crypto.transport` 创建服务端管理器。使用方只需传入顶层 `crypto` 句柄:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
import { crypto } from '@h-ai/crypto'
|
|
74
|
+
import { serv } from '@h-ai/serv'
|
|
75
|
+
|
|
76
|
+
await crypto.init()
|
|
77
|
+
|
|
78
|
+
const app = serv.createApp({
|
|
79
|
+
contract,
|
|
80
|
+
procedures,
|
|
81
|
+
http: { apiPrefix: '/api/v1' },
|
|
82
|
+
transport: {
|
|
83
|
+
crypto,
|
|
84
|
+
// keyExchangePath 默认 '/_hai/key-exchange',最终端点为 '/api/v1/_hai/key-exchange'
|
|
85
|
+
// excludePaths: ['/health'],
|
|
86
|
+
// maxClients: 10000,
|
|
87
|
+
},
|
|
88
|
+
})
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
客户端使用 `@h-ai/api-client`:
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
import { apiClient } from '@h-ai/api-client'
|
|
95
|
+
|
|
96
|
+
await apiClient.init({
|
|
97
|
+
baseUrl: 'https://api.example.com/api/v1',
|
|
98
|
+
transport: { crypto },
|
|
99
|
+
})
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
如果应用通过 `@h-ai/core` 从 `config/_serv.yml` 驱动 `ServConfigSchema`,transport 也可以一起配置:
|
|
103
|
+
|
|
104
|
+
```yaml
|
|
105
|
+
# config/_serv.yml
|
|
106
|
+
http:
|
|
107
|
+
apiPrefix: /api/v1
|
|
108
|
+
|
|
109
|
+
transport:
|
|
110
|
+
keyExchangePath: /_hai/key-exchange
|
|
111
|
+
excludePaths:
|
|
112
|
+
- /health
|
|
113
|
+
- /ready
|
|
114
|
+
- /openapi.json
|
|
115
|
+
- /docs
|
|
116
|
+
- /_hai/scalar.js
|
|
117
|
+
maxClients: 10000
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
const servConfig = core.config.getOrThrow<import('@h-ai/serv').ServConfig>('serv')
|
|
122
|
+
|
|
123
|
+
const app = serv.createApp({
|
|
124
|
+
contract,
|
|
125
|
+
procedures,
|
|
126
|
+
http: servConfig.http,
|
|
127
|
+
transport: servConfig.transport === false
|
|
128
|
+
? undefined
|
|
129
|
+
: {
|
|
130
|
+
crypto,
|
|
131
|
+
keyExchangePath: servConfig.transport.keyExchangePath,
|
|
132
|
+
excludePaths: [...servConfig.transport.excludePaths],
|
|
133
|
+
maxClients: servConfig.transport.maxClients,
|
|
134
|
+
},
|
|
135
|
+
})
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
> **多节点部署提示**:传输加密的会话密钥保存在 **服务端进程内**,多副本部署时必须满足下列条件之一才能正常工作:
|
|
139
|
+
>
|
|
140
|
+
> - 在负载均衡层启用**会话粘性(sticky session)**,确保同一客户端命中同一节点;
|
|
141
|
+
> - 或为每个客户端在每个节点首次访问时分别完成一次密钥协商;
|
|
142
|
+
> - 或后续提供分布式密钥存储(roadmap)。
|
|
143
|
+
>
|
|
144
|
+
> 单节点部署无需额外配置。
|
|
145
|
+
|
|
146
|
+
> **安全策略**:启用 `transport` 后,除 `excludePaths` 与密钥协商端点外,业务请求必须携带有效 `X-Client-Id`。缺失或未知 clientId 会返回 400;响应体无法加密、非 JSON 业务响应或超过单次加密上限时返回错误,不会明文透传业务数据。
|
|
147
|
+
|
|
148
|
+
### Token 认证(`iam` / `refreshCookie`)
|
|
149
|
+
|
|
150
|
+
`serv.createApp` 推荐直接传入顶层 `iam` 句柄,自动派生两件事:
|
|
151
|
+
|
|
152
|
+
- **access token 校验**:每个请求调用 `iam.session.verifyToken` 填充 `context.session`(不缓存)。
|
|
153
|
+
- **refresh token 轮换**:启用 `refreshCookie` 后,自动使用 `iam.session.refresh` 作为 httpOnly cookie 刷新回调。
|
|
154
|
+
|
|
155
|
+
**上下文工厂优先级**:显式 `createContext` > `verifyToken` > `iam.session.verifyToken` > 默认仅解析元数据。
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
// 推荐:传入 iam,一行打开认证与 Cookie 刷新
|
|
159
|
+
const app = serv.createApp({
|
|
160
|
+
contract,
|
|
161
|
+
procedures,
|
|
162
|
+
iam,
|
|
163
|
+
refreshCookie: {}, // 可选:启用 httpOnly cookie 刷新路径
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
// 逃脱口 1:不使用 @h-ai/iam,自定义 verifyToken
|
|
167
|
+
const app = serv.createApp({
|
|
168
|
+
contract,
|
|
169
|
+
procedures,
|
|
170
|
+
verifyToken: token => myAuthService.verify(token),
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
// 逃脱口 2(高级):多租户等额外字段 — 完全接管上下文构造
|
|
174
|
+
const app = serv.createApp({
|
|
175
|
+
contract,
|
|
176
|
+
procedures,
|
|
177
|
+
createContext: async ({ request }) => {
|
|
178
|
+
const base = serv.parseRequestContext({ request })
|
|
179
|
+
// 手动填充 session(serv 不再自动填充)
|
|
180
|
+
const result = await iam.session.verifyToken(base.accessToken ?? '')
|
|
181
|
+
return {
|
|
182
|
+
...base,
|
|
183
|
+
session: result.success ? result.data : undefined,
|
|
184
|
+
tenantId: request.headers.get('x-tenant') ?? null,
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
})
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### 自定义 procedure(认证 + 权限)
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
const p = serv.implement(myContract).$context<ServContext>()
|
|
194
|
+
|
|
195
|
+
const updateProfile = p.users.update.handler(
|
|
196
|
+
serv.requirePermission('users.write', async ({ input, context }) => {
|
|
197
|
+
return await userService.update(input.id, input)
|
|
198
|
+
}),
|
|
199
|
+
)
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### 高级:自定义 pipeline(HTTP middleware + context + procedure wrapper)
|
|
203
|
+
|
|
204
|
+
`@h-ai/serv` 的“pipeline”分三层,分别对应三种扩展方式:
|
|
205
|
+
|
|
206
|
+
1. **HTTP middleware 层**:通过 `serv.createApp({ middlewares })` 注入 Hono middleware。
|
|
207
|
+
2. **context 层**:通过 `verifyToken` / `createContext` / `serv.buildAuthContextFactory()` 定制请求上下文。
|
|
208
|
+
3. **procedure wrapper 层**:通过 `ServProcedureWrapper` / `ServGuardedProcedureWrapper` 组合认证、审计、租户校验等业务包装。
|
|
209
|
+
|
|
210
|
+
#### 1) 自定义 HTTP middleware
|
|
211
|
+
|
|
212
|
+
当你需要加请求日志、trace、限流、CORS、租户头校验等 **HTTP 层横切逻辑** 时,使用 `middlewares`:
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
import type { ServMiddleware } from '@h-ai/serv'
|
|
216
|
+
|
|
217
|
+
const requestMetrics: ServMiddleware = async (c, next) => {
|
|
218
|
+
const startedAt = Date.now()
|
|
219
|
+
await next()
|
|
220
|
+
c.header('x-response-time-ms', String(Date.now() - startedAt))
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const app = serv.createApp({
|
|
224
|
+
contract,
|
|
225
|
+
procedures,
|
|
226
|
+
http: { apiPrefix: '/api/v1' },
|
|
227
|
+
middlewares: [
|
|
228
|
+
{ middleware: requestMetrics }, // 默认挂到 '*'
|
|
229
|
+
{
|
|
230
|
+
path: '/api/v1/*',
|
|
231
|
+
middleware: async (c, next) => {
|
|
232
|
+
if (!c.req.header('x-tenant-id'))
|
|
233
|
+
return c.text('Missing x-tenant-id', 400)
|
|
234
|
+
await next()
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
],
|
|
238
|
+
})
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
执行顺序固定为:
|
|
242
|
+
|
|
243
|
+
1. 内置 `securityHeaders`
|
|
244
|
+
2. 内置 `transport`(若启用)
|
|
245
|
+
3. 你的 `middlewares`
|
|
246
|
+
4. health / refresh-cookie / OpenAPI / RPC / docs / oRPC routes
|
|
247
|
+
|
|
248
|
+
说明:
|
|
249
|
+
|
|
250
|
+
- `middlewares` 按数组顺序注册
|
|
251
|
+
- `path` 省略时默认 `'*'`
|
|
252
|
+
- 这一层直接操作 Hono `Context`,返回的是 **HTTP Response**,不是 `HaiResult`
|
|
253
|
+
|
|
254
|
+
#### 2) 自定义 context pipeline
|
|
255
|
+
|
|
256
|
+
如果你只想复用 `Bearer token -> session` 这一段逻辑,再额外补充租户、工作区、来源系统等字段,
|
|
257
|
+
优先用 `serv.buildAuthContextFactory()` 组装自定义 `createContext`:
|
|
258
|
+
|
|
259
|
+
```ts
|
|
260
|
+
const baseContext = serv.buildAuthContextFactory(token => iam.session.verifyToken(token))
|
|
261
|
+
|
|
262
|
+
const app = serv.createApp({
|
|
263
|
+
contract,
|
|
264
|
+
procedures,
|
|
265
|
+
createContext: async ({ request }) => {
|
|
266
|
+
const context = await baseContext({ request })
|
|
267
|
+
return {
|
|
268
|
+
...context,
|
|
269
|
+
tenantId: request.headers.get('x-tenant-id') ?? null,
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
})
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
什么时候用哪个:
|
|
276
|
+
|
|
277
|
+
- 只想换 token 校验实现:用 `verifyToken`
|
|
278
|
+
- 想在默认认证上下文之上追加字段:用 `buildAuthContextFactory(...) + createContext`
|
|
279
|
+
- 想完全接管上下文构造:直接用 `createContext`
|
|
280
|
+
|
|
281
|
+
#### 3) 自定义 procedure wrapper
|
|
282
|
+
|
|
283
|
+
当逻辑已经进入 oRPC procedure,需要统一做审计、租户约束、业务 guard 时,使用 procedure wrapper:
|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
import type { ServGuardedProcedureWrapper, ServProcedureWrapper } from '@h-ai/serv'
|
|
287
|
+
import { err, HaiCommonError } from '@h-ai/core'
|
|
288
|
+
import { serv } from '@h-ai/serv'
|
|
289
|
+
|
|
290
|
+
const withAudit: ServProcedureWrapper = handler => async (options) => {
|
|
291
|
+
options.context.logger.info('procedure.start', { requestId: options.context.requestId })
|
|
292
|
+
return await handler(options)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const requireTenant: ServGuardedProcedureWrapper<string> = (tenantId, handler) => async (options) => {
|
|
296
|
+
if (options.context.request.headers.get('x-tenant-id') !== tenantId) {
|
|
297
|
+
return err(
|
|
298
|
+
HaiCommonError.FORBIDDEN,
|
|
299
|
+
serv.m('serv_errorForbidden', { locale: options.context.locale }),
|
|
300
|
+
)
|
|
301
|
+
}
|
|
302
|
+
return await handler(options)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const createWidget = serv.mapHaiError(
|
|
306
|
+
serv.requireAuth(
|
|
307
|
+
withAudit(
|
|
308
|
+
requireTenant('tenant-a', async ({ input }) => widgetService.create(input)),
|
|
309
|
+
),
|
|
310
|
+
),
|
|
311
|
+
)
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
说明:
|
|
315
|
+
|
|
316
|
+
- `ServProcedureWrapper`:单参数 wrapper,适合 `withAudit(handler)` 这种模式
|
|
317
|
+
- `ServGuardedProcedureWrapper<T>`:带配置参数的 wrapper,适合 `requireTenant('t1', handler)` 这种模式
|
|
318
|
+
- procedure wrapper 返回的是 `HaiResult<T>`,和 `serv.requireAuth` / `serv.requirePermission` 保持一致
|
|
319
|
+
- 常规场景优先继续用 `serv.requireAuth / requirePermission / requireRole / mapHaiError`
|
|
320
|
+
|
|
321
|
+
边界约束:
|
|
322
|
+
|
|
323
|
+
- `src/pipelines/*` 是 `@h-ai/serv` 的内部默认实现目录,不作为公开子路径 API 承诺
|
|
324
|
+
- 对应用层公开的稳定入口仍然是根入口 `@h-ai/serv`
|
|
325
|
+
- `securityHeaders` / `requireInternalRPC` / `buildHaiErrorBody` 等内部 helper/middleware 不作为单独子路径文档暴露
|
|
326
|
+
|
|
327
|
+
### httpOnly Cookie 认证(推荐生产方案)
|
|
328
|
+
|
|
329
|
+
httpOnly cookie 模式将 refresh token 存储在服务端管理的 cookie 中(浏览器 JS 无法读取),避免 XSS 风险。
|
|
330
|
+
|
|
331
|
+
先把两个 token 的职责分清:
|
|
332
|
+
|
|
333
|
+
- `accessToken`:短期凭证。客户端把它放在内存中,并通过 `Authorization: Bearer <token>` 发送。
|
|
334
|
+
`serv.parseRequestContext()` / `extractBearerToken()` 解析的就是它;`buildAuthContextFactory()` 校验的也是它。
|
|
335
|
+
- `refreshToken`:长期凭证。启用 `refreshCookie` 后,它不会再暴露给前端 JS,而是只保存在浏览器的 httpOnly cookie 中。
|
|
336
|
+
它**不会**被 `extractBearerToken()` 读取,只会在 `/auth/refresh` 被服务端取出,用来换发新的 access token。
|
|
337
|
+
|
|
338
|
+
**工作原理:**
|
|
339
|
+
|
|
340
|
+
1. 浏览器登录 → 服务端在响应中 `Set-Cookie: hai_refresh_token=...;HttpOnly`,并从 JSON 响应体擦除 `refreshToken`
|
|
341
|
+
2. Access token 存储在客户端内存(不持久化)
|
|
342
|
+
3. Access token 过期时,浏览器自动携带 cookie 访问 `/auth/refresh`
|
|
343
|
+
4. 服务端读取 cookie → 调用 `onRefresh` → 返回新 access token + 更新 cookie(响应体不暴露 refresh token)
|
|
344
|
+
|
|
345
|
+
**服务端配置:**
|
|
346
|
+
|
|
347
|
+
```ts
|
|
348
|
+
// 服务端:api-service/src/app.ts
|
|
349
|
+
const app = serv.createApp({
|
|
350
|
+
contract,
|
|
351
|
+
procedures,
|
|
352
|
+
http: { apiPrefix: '/api/v1' },
|
|
353
|
+
iam, // 顶层 iam 同时驱动 verifyToken 与 refresh
|
|
354
|
+
refreshCookie: {
|
|
355
|
+
// cookieName: 'hai_refresh_token', // 默认
|
|
356
|
+
// maxAge: 30 * 24 * 3600, // 默认 30 天
|
|
357
|
+
// secure: true, // 默认在 NODE_ENV=production 时开启
|
|
358
|
+
// onRefresh: customFn, // 可覆盖 iam.session.refresh。
|
|
359
|
+
},
|
|
360
|
+
// ✅ 无需再传 verifyToken,iam.session.verifyToken 自动用于填充 context.session
|
|
361
|
+
})
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
**客户端配置(与 `@h-ai/api-client` 配合):**
|
|
365
|
+
|
|
366
|
+
```ts
|
|
367
|
+
// 默认存储即 httpOnly cookie,refreshPath 默认 /auth/refresh,无需额外配置。
|
|
368
|
+
await apiClient.init({
|
|
369
|
+
baseUrl: 'https://api.example.com/api/v1',
|
|
370
|
+
auth: {},
|
|
371
|
+
})
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
> **Cookie 规格**:`HttpOnly; SameSite=Strict; Secure`(生产);`Path` 限制为 `{apiPrefix}/auth/refresh`。
|
|
375
|
+
|
|
376
|
+
## API 概览
|
|
377
|
+
|
|
378
|
+
- `serv.createApp(options)`:创建 Hono app,挂载自定义 `middlewares`、健康检查、OpenAPI handler、可选文档与 RPC
|
|
379
|
+
- `serv.parseRequestContext({ request })`:默认上下文解析(提取 Bearer token + requestId,不填充 session)
|
|
380
|
+
- `serv.listen(app, options)`:在 Node.js 启动 HTTP 服务,返回 `{ server, address, close }`
|
|
381
|
+
- `serv.toFetch(app)`:包装为标准 `fetch(Request)` handler
|
|
382
|
+
- `serv.generateSpec(contract, options)`:由 contract 生成 OpenAPI 3.1 spec
|
|
383
|
+
- `serv.requireAuth(handler)`:procedure 认证包装器(`context.session` 为空 → UNAUTHORIZED)
|
|
384
|
+
- `serv.requirePermission(perm, handler)`:procedure 权限包装器(缺失权限 → FORBIDDEN)
|
|
385
|
+
- `serv.requireRole(role, handler)`:procedure 角色包装器(缺失角色 → FORBIDDEN)
|
|
386
|
+
- `serv.mapHaiError(handler)`:统一异常 → `HaiResult` 的包装器
|
|
387
|
+
- `serv.validateInputOrFail(zodSchema, input, locale)`:在 procedure 内做 Zod 二次校验,失败时返回本地化 `HaiResult` + `ValidationFormError[]`
|
|
388
|
+
- `serv.resolveRequestLocale(headers)`:从 `x-hai-locale` / `Accept-Language` 解析并规范化请求 locale
|
|
389
|
+
- `serv.m(key, options)`:读取 `@h-ai/serv` 自身消息,支持 `options.locale` 单次调用本地化
|
|
390
|
+
|
|
391
|
+
另有根级类型 / 命名导出:
|
|
392
|
+
|
|
393
|
+
- `ServConfigSchema`:用于 `core.config.validate('serv', ServConfigSchema)` 校验 `config/_serv.yml`
|
|
394
|
+
- `ServMiddlewareMount`:`createApp({ middlewares })` 的挂载项类型(`{ path?, middleware }`)
|
|
395
|
+
- `ServMiddleware` / `ServProcedureWrapper` / `ServGuardedProcedureWrapper`:自定义 pipeline 时复用的共享类型
|
|
396
|
+
|
|
397
|
+
> 传输加密不作为 `serv.xxx` 扁平 API 暴露;它是 `createApp` 的配置能力,内部委托 `crypto.transport`。
|
|
398
|
+
|
|
399
|
+
## 配置
|
|
400
|
+
|
|
401
|
+
`@h-ai/serv` 本身不主动扫描 YAML;推荐由 `@h-ai/core` 统一加载 `config/_serv.yml`,再把解析结果传给 `serv.createApp()`。
|
|
402
|
+
|
|
403
|
+
```ts
|
|
404
|
+
import { core } from '@h-ai/core'
|
|
405
|
+
import { serv, ServConfigSchema } from '@h-ai/serv'
|
|
406
|
+
|
|
407
|
+
core.init({ configDir: './config' })
|
|
408
|
+
|
|
409
|
+
const validation = core.config.validate('serv', ServConfigSchema)
|
|
410
|
+
if (!validation.success)
|
|
411
|
+
throw new Error(validation.error.message)
|
|
412
|
+
|
|
413
|
+
const servConfig = core.config.getOrThrow<import('@h-ai/serv').ServConfig>('serv')
|
|
414
|
+
|
|
415
|
+
const app = serv.createApp({
|
|
416
|
+
contract,
|
|
417
|
+
procedures,
|
|
418
|
+
http: servConfig.http,
|
|
419
|
+
transport: servConfig.transport === false
|
|
420
|
+
? undefined
|
|
421
|
+
: {
|
|
422
|
+
crypto,
|
|
423
|
+
keyExchangePath: servConfig.transport.keyExchangePath,
|
|
424
|
+
excludePaths: [...servConfig.transport.excludePaths],
|
|
425
|
+
maxClients: servConfig.transport.maxClients,
|
|
426
|
+
},
|
|
427
|
+
})
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
`config/_serv.yml` 示例:
|
|
431
|
+
|
|
432
|
+
```yaml
|
|
433
|
+
http:
|
|
434
|
+
apiPrefix: /api/v1
|
|
435
|
+
openapi:
|
|
436
|
+
path: /openapi.json
|
|
437
|
+
docs:
|
|
438
|
+
path: /docs
|
|
439
|
+
health:
|
|
440
|
+
path: /health
|
|
441
|
+
readyPath: /ready
|
|
442
|
+
rpc: false
|
|
443
|
+
|
|
444
|
+
transport:
|
|
445
|
+
keyExchangePath: /_hai/key-exchange
|
|
446
|
+
excludePaths:
|
|
447
|
+
- /health
|
|
448
|
+
- /ready
|
|
449
|
+
- /openapi.json
|
|
450
|
+
- /docs
|
|
451
|
+
- /_hai/scalar.js
|
|
452
|
+
maxClients: 10000
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
`ServHttpConfigInput`:
|
|
456
|
+
|
|
457
|
+
- `apiPrefix`:默认 `/api/v1`
|
|
458
|
+
- `health`:默认 `{ path: '/health', readyPath: '/ready' }`
|
|
459
|
+
- `openapi`:默认 `false`;启用:`{ path: '/openapi.json' }`
|
|
460
|
+
- `docs`:默认 `false`;启用:`{ path: '/docs' }`(依赖 `openapi`);启用后自动挂载 `/_hai/scalar.js`,从 `@scalar/api-reference` 的 browser bundle 提供本地 Scalar UI 脚本,无需外网 CDN
|
|
461
|
+
- `rpc`:默认 `false`;启用:`{ prefix: '/rpc', access: 'loopback' | 'private-network' | 'gateway-only', gatewayHeader?, gatewaySecret? }`
|
|
462
|
+
- `transport`:`config/_serv.yml` 顶层配置;启用后可通过 `servConfig.transport` 装配到 `createApp({ transport })`,默认密钥协商子路径 `/_hai/key-exchange`
|
|
463
|
+
|
|
464
|
+
## 错误处理
|
|
465
|
+
|
|
466
|
+
- Default procedures 全部返回 `HaiResult<T>`,客户端直接判断 `result.success`
|
|
467
|
+
- 认证失败 → `HaiCommonError.UNAUTHORIZED`(由 `requireAuth` 按 `context.locale` 本地化)
|
|
468
|
+
- 授权失败 → `HaiCommonError.FORBIDDEN`(由 `requirePermission` / `requireRole` 按 `context.locale` 本地化)
|
|
469
|
+
- 未捕获异常 → `HaiCommonError.INTERNAL_ERROR`(由 `mapHaiError` 统一兜底并按 `context.locale` 本地化)
|
|
470
|
+
|
|
471
|
+
正常业务请求建议按三层处理:
|
|
472
|
+
|
|
473
|
+
1. **oRPC contract 输入校验**:进入 procedure 前由 oRPC 执行;`serv.createApp()` 内部会自动拦截并把 Zod 默认英文错误重写为本地化 `errors[]`。
|
|
474
|
+
2. **procedure 内二次校验**:对数据库读回对象、跨字段约束等适合 Zod 表达的规则,使用 `serv.validateInputOrFail(zodSchema, input, context.locale)`;简单业务规则直接 `err(...)` 即可,不必为了统一额外造 schema。
|
|
475
|
+
3. **业务/领域错误**:不要 throw 预期业务失败;直接返回 `err(...)`,并在**创建错误消息的那一层**使用对应模块的 i18n getter,例如 `serv.m(..., { locale: context.locale })` 或 `widgetM(..., { locale: context.locale })`。
|
|
476
|
+
|
|
477
|
+
```ts
|
|
478
|
+
import { err, HaiCommonError } from '@h-ai/core'
|
|
479
|
+
import { serv } from '@h-ai/serv'
|
|
480
|
+
import { z } from 'zod'
|
|
481
|
+
|
|
482
|
+
const CreateWidgetSchema = z.object({
|
|
483
|
+
title: z.string().min(1),
|
|
484
|
+
slug: z.string().min(3),
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
const createWidget = serv.mapHaiError(async ({ input, context }) => {
|
|
488
|
+
const validated = serv.validateInputOrFail(CreateWidgetSchema, input, context.locale)
|
|
489
|
+
if (!validated.success)
|
|
490
|
+
return validated
|
|
491
|
+
|
|
492
|
+
if (validated.data.slug === 'admin') {
|
|
493
|
+
return err(
|
|
494
|
+
HaiCommonError.FORBIDDEN,
|
|
495
|
+
serv.m('serv_errorForbidden', { locale: context.locale }),
|
|
496
|
+
)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return widgetService.create(validated.data)
|
|
500
|
+
})
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
> 如果错误来自下游业务模块(如 `iam.user.xxx()`)且该模块已经直接返回 `HaiResult`,serv 会原样透传它的 `error.message`。当前 `HaiError` 只携带最终 `message`,不携带 `messageKey/params`,因此 **serv 边界无法对任意下游错误再做一次通用 i18n 重翻译**;要支持请求级 i18n,必须在创建该错误消息的模块里就拿到 locale。
|
|
504
|
+
|
|
505
|
+
## 测试
|
|
506
|
+
|
|
507
|
+
```bash
|
|
508
|
+
pnpm --filter @h-ai/serv test
|
|
509
|
+
pnpm --filter @h-ai/serv typecheck
|
|
510
|
+
pnpm --filter @h-ai/serv lint
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
## License
|
|
514
|
+
|
|
515
|
+
Apache-2.0
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { ok } from '@h-ai/core';
|
|
2
|
+
|
|
3
|
+
// src/features/serv-feature-helpers.ts
|
|
4
|
+
function wrapItemsResult(result) {
|
|
5
|
+
if (!result.success)
|
|
6
|
+
return result;
|
|
7
|
+
return ok({ items: result.data });
|
|
8
|
+
}
|
|
9
|
+
function mapHaiResult(result, mapper) {
|
|
10
|
+
if (!result.success)
|
|
11
|
+
return result;
|
|
12
|
+
return ok(mapper(result.data));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export { mapHaiResult, wrapItemsResult };
|
|
16
|
+
//# sourceMappingURL=chunk-27PCPE37.js.map
|
|
17
|
+
//# sourceMappingURL=chunk-27PCPE37.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/features/serv-feature-helpers.ts"],"names":[],"mappings":";;;AAgBO,SAAS,gBAAmB,MAAA,EAAmD;AACpF,EAAA,IAAI,CAAC,MAAA,CAAO,OAAA;AACV,IAAA,OAAO,MAAA;AACT,EAAA,OAAO,EAAA,CAAG,EAAE,KAAA,EAAO,MAAA,CAAO,MAAM,CAAA;AAClC;AAKO,SAAS,YAAA,CAAmB,QAAsB,MAAA,EAAsC;AAC7F,EAAA,IAAI,CAAC,MAAA,CAAO,OAAA;AACV,IAAA,OAAO,MAAA;AACT,EAAA,OAAO,EAAA,CAAG,MAAA,CAAO,MAAA,CAAO,IAAI,CAAC,CAAA;AAC/B","file":"chunk-27PCPE37.js","sourcesContent":["/**\n * @h-ai/serv — features 公共辅助\n *\n * 把多个 feature procedure 中重复出现的 `HaiResult` 包装逻辑收敛到一处。\n * @module features/serv-feature-helpers\n */\n\nimport type { HaiResult } from '@h-ai/core'\nimport { ok } from '@h-ai/core'\n\n/**\n * 把 `HaiResult<T[]>` 转换为 `HaiResult<{ items: T[] }>`。\n *\n * oRPC contract 中 list 类返回常用 `{ items: [...] }` 信封;\n * 业务模块通常直接返回数组,本工具负责套上信封。\n */\nexport function wrapItemsResult<T>(result: HaiResult<T[]>): HaiResult<{ items: T[] }> {\n if (!result.success)\n return result\n return ok({ items: result.data })\n}\n\n/**\n * 把 `HaiResult<T>` 通过 mapper 映射为 `HaiResult<U>`,错误透传。\n */\nexport function mapHaiResult<T, U>(result: HaiResult<T>, mapper: (data: T) => U): HaiResult<U> {\n if (!result.success)\n return result\n return ok(mapper(result.data))\n}\n"]}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { core, err, HaiCommonError } from '@h-ai/core';
|
|
2
|
+
|
|
3
|
+
// src/serv-i18n.ts
|
|
4
|
+
|
|
5
|
+
// messages/en-US.json
|
|
6
|
+
var en_US_default = {
|
|
7
|
+
$schema: "https://inlang.com/schema/inlang-message-format",
|
|
8
|
+
serv_validationFailed: "Validation failed",
|
|
9
|
+
serv_validationRequired: "This field is required",
|
|
10
|
+
serv_validationInvalid: "Invalid input format",
|
|
11
|
+
serv_validationInvalidType: "Invalid field type",
|
|
12
|
+
serv_validationStringMin: "Enter at least {min} characters",
|
|
13
|
+
serv_validationStringMax: "Enter at most {max} characters",
|
|
14
|
+
serv_validationNumberMin: "Value must be at least {min}",
|
|
15
|
+
serv_validationNumberMax: "Value must be at most {max}",
|
|
16
|
+
serv_validationArrayMin: "Select at least {min} item(s)",
|
|
17
|
+
serv_validationArrayMax: "Select at most {max} item(s)",
|
|
18
|
+
serv_validationTooSmall: "Value must be at least {min}",
|
|
19
|
+
serv_validationTooBig: "Value must be at most {max}",
|
|
20
|
+
serv_validationEmail: "Enter a valid email address",
|
|
21
|
+
serv_validationUrl: "Enter a valid URL",
|
|
22
|
+
serv_validationUuid: "Enter a valid UUID",
|
|
23
|
+
serv_validationEnum: "Select a valid option",
|
|
24
|
+
serv_errorUnauthorized: "Unauthorized",
|
|
25
|
+
serv_errorForbidden: "Forbidden",
|
|
26
|
+
serv_errorInternal: "Internal server error",
|
|
27
|
+
serv_storageKeyRequired: "Storage key must not be empty",
|
|
28
|
+
serv_storageKeyTooLong: "Storage key must not exceed {max} characters",
|
|
29
|
+
serv_storageKeyIllegalChars: "Storage key contains illegal characters",
|
|
30
|
+
serv_storageKeyDotSegments: "Storage key must not contain . or .. path segments",
|
|
31
|
+
serv_iamCannotDeleteCurrentUser: "Cannot delete the current logged-in user",
|
|
32
|
+
serv_transportInvalidPayload: "Invalid encrypted transport payload",
|
|
33
|
+
serv_transportUnknownClient: "Unknown encryption client; key exchange required",
|
|
34
|
+
serv_transportClientIdRequired: "Missing X-Client-Id header; key exchange required",
|
|
35
|
+
serv_transportClientKeyNotFound: "Client not registered or session expired; complete key exchange again",
|
|
36
|
+
serv_transportDecryptFailed: "Failed to decrypt request",
|
|
37
|
+
serv_transportEncryptFailed: "Failed to encrypt response",
|
|
38
|
+
serv_transportKeyExchangeFailed: "Key exchange failed"
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// messages/zh-CN.json
|
|
42
|
+
var zh_CN_default = {
|
|
43
|
+
$schema: "https://inlang.com/schema/inlang-message-format",
|
|
44
|
+
serv_validationFailed: "\u6570\u636E\u9A8C\u8BC1\u5931\u8D25",
|
|
45
|
+
serv_validationRequired: "\u6B64\u5B57\u6BB5\u5FC5\u586B",
|
|
46
|
+
serv_validationInvalid: "\u8F93\u5165\u683C\u5F0F\u65E0\u6548",
|
|
47
|
+
serv_validationInvalidType: "\u5B57\u6BB5\u7C7B\u578B\u4E0D\u6B63\u786E",
|
|
48
|
+
serv_validationStringMin: "\u81F3\u5C11\u8F93\u5165 {min} \u4E2A\u5B57\u7B26",
|
|
49
|
+
serv_validationStringMax: "\u6700\u591A\u8F93\u5165 {max} \u4E2A\u5B57\u7B26",
|
|
50
|
+
serv_validationNumberMin: "\u6570\u503C\u4E0D\u80FD\u5C0F\u4E8E {min}",
|
|
51
|
+
serv_validationNumberMax: "\u6570\u503C\u4E0D\u80FD\u5927\u4E8E {max}",
|
|
52
|
+
serv_validationArrayMin: "\u81F3\u5C11\u9009\u62E9 {min} \u9879",
|
|
53
|
+
serv_validationArrayMax: "\u6700\u591A\u9009\u62E9 {max} \u9879",
|
|
54
|
+
serv_validationTooSmall: "\u4E0D\u80FD\u5C0F\u4E8E {min}",
|
|
55
|
+
serv_validationTooBig: "\u4E0D\u80FD\u5927\u4E8E {max}",
|
|
56
|
+
serv_validationEmail: "\u8BF7\u8F93\u5165\u6709\u6548\u7684\u90AE\u7BB1\u5730\u5740",
|
|
57
|
+
serv_validationUrl: "\u8BF7\u8F93\u5165\u6709\u6548\u7684\u7F51\u5740",
|
|
58
|
+
serv_validationUuid: "\u8BF7\u8F93\u5165\u6709\u6548\u7684 UUID",
|
|
59
|
+
serv_validationEnum: "\u8BF7\u9009\u62E9\u6709\u6548\u9009\u9879",
|
|
60
|
+
serv_errorUnauthorized: "\u672A\u767B\u5F55\u6216\u767B\u5F55\u5DF2\u5931\u6548",
|
|
61
|
+
serv_errorForbidden: "\u65E0\u6743\u6267\u884C\u8BE5\u64CD\u4F5C",
|
|
62
|
+
serv_errorInternal: "\u670D\u52A1\u5668\u5185\u90E8\u9519\u8BEF",
|
|
63
|
+
serv_storageKeyRequired: "\u5B58\u50A8 key \u4E0D\u80FD\u4E3A\u7A7A",
|
|
64
|
+
serv_storageKeyTooLong: "\u5B58\u50A8 key \u4E0D\u80FD\u8D85\u8FC7 {max} \u4E2A\u5B57\u7B26",
|
|
65
|
+
serv_storageKeyIllegalChars: "\u5B58\u50A8 key \u5305\u542B\u975E\u6CD5\u5B57\u7B26",
|
|
66
|
+
serv_storageKeyDotSegments: "\u5B58\u50A8 key \u4E0D\u80FD\u5305\u542B . \u6216 .. \u8DEF\u5F84\u6BB5",
|
|
67
|
+
serv_iamCannotDeleteCurrentUser: "\u4E0D\u80FD\u5220\u9664\u5F53\u524D\u767B\u5F55\u7528\u6237",
|
|
68
|
+
serv_transportInvalidPayload: "\u4F20\u8F93\u5C42\u52A0\u5BC6\u8F7D\u8377\u683C\u5F0F\u65E0\u6548",
|
|
69
|
+
serv_transportUnknownClient: "\u672A\u8BC6\u522B\u7684\u52A0\u5BC6\u5BA2\u6237\u7AEF\uFF0C\u8BF7\u5148\u5B8C\u6210\u5BC6\u94A5\u534F\u5546",
|
|
70
|
+
serv_transportClientIdRequired: "\u7F3A\u5C11 X-Client-Id \u8BF7\u6C42\u5934\uFF0C\u8BF7\u5148\u5B8C\u6210\u5BC6\u94A5\u534F\u5546",
|
|
71
|
+
serv_transportClientKeyNotFound: "\u5BA2\u6237\u7AEF\u672A\u6CE8\u518C\u6216\u4F1A\u8BDD\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u5B8C\u6210\u5BC6\u94A5\u534F\u5546",
|
|
72
|
+
serv_transportDecryptFailed: "\u8BF7\u6C42\u89E3\u5BC6\u5931\u8D25",
|
|
73
|
+
serv_transportEncryptFailed: "\u54CD\u5E94\u52A0\u5BC6\u5931\u8D25",
|
|
74
|
+
serv_transportKeyExchangeFailed: "\u5BC6\u94A5\u534F\u5546\u5931\u8D25"
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// src/serv-i18n.ts
|
|
78
|
+
var SUPPORTED_SERV_LOCALES = ["zh-CN", "en-US"];
|
|
79
|
+
var DEFAULT_SERV_LOCALE = "zh-CN";
|
|
80
|
+
function normalizeServLocale(locale) {
|
|
81
|
+
const candidate = locale?.split(",")[0]?.trim();
|
|
82
|
+
if (!candidate)
|
|
83
|
+
return DEFAULT_SERV_LOCALE;
|
|
84
|
+
const exact = SUPPORTED_SERV_LOCALES.find((item) => item.toLowerCase() === candidate.toLowerCase());
|
|
85
|
+
if (exact)
|
|
86
|
+
return exact;
|
|
87
|
+
const baseLanguage = candidate.split("-")[0]?.toLowerCase();
|
|
88
|
+
if (baseLanguage === "en")
|
|
89
|
+
return "en-US";
|
|
90
|
+
if (baseLanguage === "zh")
|
|
91
|
+
return "zh-CN";
|
|
92
|
+
return DEFAULT_SERV_LOCALE;
|
|
93
|
+
}
|
|
94
|
+
var servM = core.i18n.createMessageGetter({ "zh-CN": zh_CN_default, "en-US": en_US_default });
|
|
95
|
+
function buildHaiErrorBody(def, message) {
|
|
96
|
+
return {
|
|
97
|
+
success: false,
|
|
98
|
+
error: {
|
|
99
|
+
code: def.code,
|
|
100
|
+
message,
|
|
101
|
+
httpStatus: def.httpStatus,
|
|
102
|
+
system: def.system,
|
|
103
|
+
module: def.module
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function mapHaiError(handler) {
|
|
108
|
+
return async (options) => {
|
|
109
|
+
try {
|
|
110
|
+
return await handler(options);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
return err(HaiCommonError.INTERNAL_ERROR, servM("serv_errorInternal", { locale: options.context.locale }), error);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// src/pipelines/serv-pipeline-require-auth.ts
|
|
118
|
+
function requireAuth(handler) {
|
|
119
|
+
return mapHaiError(async (options) => {
|
|
120
|
+
if (!options.context.session)
|
|
121
|
+
return err(HaiCommonError.UNAUTHORIZED, servM("serv_errorUnauthorized", { locale: options.context.locale }));
|
|
122
|
+
return handler(options);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export { buildHaiErrorBody, mapHaiError, normalizeServLocale, requireAuth, servM };
|
|
127
|
+
//# sourceMappingURL=chunk-4QBF4PUG.js.map
|
|
128
|
+
//# sourceMappingURL=chunk-4QBF4PUG.js.map
|