@h-ai/cli 0.1.0-alpha.46 → 0.1.0-alpha.48

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@h-ai/cli",
3
3
  "type": "module",
4
- "version": "0.1.0-alpha.46",
4
+ "version": "0.1.0-alpha.48",
5
5
  "description": "Hai Framework CLI for project scaffolding and code generation.",
6
6
  "license": "Apache-2.0",
7
7
  "keywords": [
@@ -34,7 +34,7 @@
34
34
  "ora": "^9.1.0",
35
35
  "prompts": "^2.4.2",
36
36
  "yaml": "^2.7.0",
37
- "@h-ai/core": "0.1.0-alpha.46"
37
+ "@h-ai/core": "0.1.0-alpha.48"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/fs-extra": "^11.0.4",
@@ -2,6 +2,10 @@
2
2
  # {{projectName}} API Service - Serv / HTTP 入口配置
3
3
  # =============================================================================
4
4
 
5
+ server:
6
+ host: 127.0.0.1
7
+ port: 3000
8
+
5
9
  http:
6
10
  apiPrefix: /api/v1
7
11
  openapi:
@@ -1,3 +1,4 @@
1
+ import type { ServConfig } from '@h-ai/serv'
1
2
  import process from 'node:process'
2
3
  import { core } from '@h-ai/core'
3
4
  import { serv } from '@h-ai/serv'
@@ -9,7 +10,11 @@ const logger = core.logger.child({ app: '{{api.servicePackageName}}', scope: 'en
9
10
  async function main(): Promise<void> {
10
11
  await initApp()
11
12
 
13
+ // host/port 来自 config/_serv.yml 的 server(HOST/PORT 环境变量高于配置文件)。
14
+ const { server } = core.config.getOrThrow<ServConfig>('serv')
12
15
  serv.listen({{api.serviceAppFactoryName}}(), {
16
+ host: server.host,
17
+ port: server.port,
13
18
  onListening: info => logger.info('API service listening', { address: info.address, port: info.port }),
14
19
  onClose: closeApp,
15
20
  })
@@ -1,7 +1,5 @@
1
1
  import type { ServContext } from '@h-ai/serv'
2
- {{#if features.iam}}import type { AppEchoInput, AppEchoOutputData, AppInfoOutputData } from '{{api.contractPackageName}}'
3
- {{else}}import type { AppEchoOutputData, AppInfoOutputData } from '{{api.contractPackageName}}'
4
- {{/if}}
2
+ import type { AppEchoOutputData, AppInfoOutputData } from '{{api.contractPackageName}}'
5
3
  import { ok } from '@h-ai/core'
6
4
  import { serv } from '@h-ai/serv'
7
5
  import { appContract } from '{{api.contractPackageName}}'
@@ -15,12 +13,12 @@ export interface AppProcedureDeps {
15
13
  readonly startedAt: number
16
14
  }
17
15
 
18
- const p = serv.implement(appContract).$context<ServContext>()
19
-
20
16
  /** 创建应用自有 procedures。 */
21
17
  export function createAppProcedures(deps: AppProcedureDeps) {
22
- return p.router({
23
- info: p.info.handler(() => {
18
+ return serv
19
+ .implement(appContract)
20
+ .context<ServContext>()
21
+ .route('info', () => {
24
22
  const data: AppInfoOutputData = {
25
23
  name: deps.info.name,
26
24
  version: deps.info.version,
@@ -28,22 +26,28 @@ export function createAppProcedures(deps: AppProcedureDeps) {
28
26
  uptimeMs: Math.max(0, Date.now() - deps.startedAt),
29
27
  }
30
28
  return ok(data)
31
- }),
32
- echo: {{#if features.iam}}p.echo.handler(serv.requireAuth<AppEchoInput, AppEchoOutputData>(({ input, context }) => {
29
+ })
30
+ {{#if features.iam}}
31
+ .route('echo')
32
+ .auth()
33
+ .handle(({ input, context }) => {
33
34
  const data: AppEchoOutputData = {
34
35
  message: input.message,
35
- userId: context.session!.userId,
36
+ userId: context.session.userId,
36
37
  requestId: context.requestId,
37
38
  timestamp: new Date().toISOString(),
38
39
  }
39
- return Promise.resolve(ok(data))
40
- })){{else}}p.echo.handler(({ input, context }) => {
40
+ return ok(data)
41
+ })
42
+ {{else}}
43
+ .route('echo', ({ input, context }) => {
41
44
  const data: AppEchoOutputData = {
42
45
  message: input.message,
43
46
  requestId: context.requestId,
44
47
  timestamp: new Date().toISOString(),
45
48
  }
46
49
  return ok(data)
47
- }){{/if}},
48
- })
50
+ })
51
+ {{/if}}
52
+ .build()
49
53
  }
@@ -5,6 +5,10 @@
5
5
  # 业务接口路径直接写在对应 *-contract.ts;公共 HTTP 传输配置以本文件为准。
6
6
  # =============================================================================
7
7
 
8
+ server:
9
+ host: 127.0.0.1
10
+ port: 3000
11
+
8
12
  http:
9
13
  apiPrefix: /api/v1
10
14
  openapi:
@@ -1,18 +1,18 @@
1
- import process from 'node:process'
1
+ import type { ServConfigInput } from '@h-ai/serv'
2
2
  import { core } from '@h-ai/core'
3
- import { serv } from '@h-ai/serv'
3
+ import { serv, ServConfigSchema } from '@h-ai/serv'
4
4
  import { createServerApp } from './app-server.js'
5
5
 
6
6
  // 加载 config/ 下的 _core.yml / _serv.yml 等配置文件(约定优于配置)
7
7
  core.init({ configDir: './config', logging: { level: 'info' } })
8
8
 
9
+ // host/port 来自 config/_serv.yml 的 server(HOST/PORT 环境变量高于配置文件)。
10
+ const { server } = ServConfigSchema.parse(core.config.get<ServConfigInput>('serv') ?? {})
9
11
  const app = createServerApp()
10
- const port = process.env.PORT ? Number(process.env.PORT) : 3000
11
- const host = process.env.HOST ?? '127.0.0.1'
12
12
  const listenApp = app as Parameters<typeof serv.listen>[0]
13
13
 
14
14
  serv.listen(listenApp, {
15
- port,
16
- host,
17
- onListening: info => core.logger.info('{{fullstack.servPackageName}} listening', { host, port: info.port }),
15
+ host: server.host,
16
+ port: server.port,
17
+ onListening: info => core.logger.info('{{fullstack.servPackageName}} listening', { host: server.host, port: info.port }),
18
18
  })
@@ -11,21 +11,21 @@ export interface CreateAppProceduresOptions {
11
11
  readonly frontends: readonly AppFrontendTarget[]
12
12
  }
13
13
 
14
- const p = serv.implement(appContract).$context<ServContext>()
15
-
16
14
  /** app 领域 procedures:把共享 contract 绑定到真实业务处理函数。 */
17
15
  export function createAppProcedures(options: CreateAppProceduresOptions) {
18
- return p.router({
19
- info: p.info.handler(() => ok(createAppInfo(options))),
20
- echo: p.echo.handler(({ input, context }) => {
16
+ return serv
17
+ .implement(appContract)
18
+ .context<ServContext>()
19
+ .route('info', () => ok(createAppInfo(options)))
20
+ .route('echo', ({ input, context }) => {
21
21
  const data: AppEchoOutputData = {
22
22
  message: input.message,
23
23
  echoedAt: new Date().toISOString(),
24
24
  requestId: context.requestId,
25
25
  }
26
26
  return ok(data)
27
- }),
28
- })
27
+ })
28
+ .build()
29
29
  }
30
30
 
31
31
  function createAppInfo(options: CreateAppProceduresOptions): AppInfoOutputData {
@@ -35,4 +35,4 @@ function createAppInfo(options: CreateAppProceduresOptions): AppInfoOutputData {
35
35
  startedAt: options.startedAt,
36
36
  frontends: [...options.frontends],
37
37
  }
38
- }
38
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "{{projectName}}",
3
- "version": "0.1.0-alpha.46",
3
+ "version": "0.1.0-alpha.48",
4
4
  "private": true,
5
5
  "description": "{{#if_eq appType 'admin'}}Enterprise management system built with hai Agent Framework{{/if_eq}}{{#if_eq appType 'website'}}Enterprise website built with hai Agent Framework{{/if_eq}}{{#if_eq appType 'h5'}}Mobile H5 app built with hai Agent Framework{{/if_eq}}{{#if_eq appType 'api'}}REST API service built with hai Agent Framework{{/if_eq}}{{#if_eq appType 'mobile-app'}}Mobile app built with hai Agent Framework{{/if_eq}}",
6
6
  "keywords": ["hai", {{#unless isSvelteOnlyApp}}"sveltekit", {{/unless}}"svelte5", "{{appType}}"],
@@ -126,7 +126,7 @@ memory:
126
126
  | Context 并发 | `createManager({ concurrency: 'reject' \| 'queue' })` | 默认单活动生成防消息乱序;活动生成期间新 chat 返回 `CONTEXT_BUSY`(reject)或排队(queue) |
127
127
  | Context 重置 | `await manager.reset({ preserveSystemPrompt?, cancelActiveTurn?, waitForMemoryTasks? })` | 异步:终止活动生成、清空消息/摘要/轮次,默认保留系统提示词 |
128
128
  | 语音识别 | `ai.audio.transcribe` / `transcribeStream` | 完整音频返回 `HaiResult`;持续音频输入用 `AudioInputStream` 流式返回临时文本 |
129
- | 语音合成 | `ai.audio.synthesize` / `synthesizeStream` | `text` 可为字符串或 `AsyncIterable<string>`(可接 LLM 文本流边生成边合成) |
129
+ | 语音合成 | `ai.audio.synthesize` / `synthesizeStream` | `text` 可为字符串或 `AsyncIterable<string>`(可接 LLM 文本流边生成边合成);完整结果可用 `serializePlayableAudio` 转为浏览器播放负载 |
130
130
  | 图片生成 | `ai.image.generate({ prompt, size?, model?, referenceImages? })` | 可选参考图使用字节 + MIME;Provider 协议和临时 URL 下载由模块内部处理 |
131
131
  | A2A | `ai.a2a.registerExecutor/handleRequest` | 延迟初始化 SDK handler |
132
132
 
@@ -317,6 +317,20 @@ for await (const event of ai.audio.synthesizeStream({ text: { id: 'seg-1', text:
317
317
  }
318
318
  ```
319
319
 
320
+ 需要通过 API 返回浏览器可播放的 Base64 音频时,调用 `serializePlayableAudio(result.data)`;该函数会将 `pcm16` 封装为 WAV,并透传 WAV/MP3,`opus` 不支持该转换。
321
+
322
+ ```ts
323
+ import { serializePlayableAudio } from '@h-ai/ai'
324
+
325
+ const synthesized = await ai.audio.synthesize({ text: '欢迎参加访谈。' })
326
+ if (!synthesized.success)
327
+ return synthesized
328
+
329
+ const playable = serializePlayableAudio(synthesized.data)
330
+ if (!playable.success)
331
+ return playable
332
+ ```
333
+
320
334
  - 流式方法是 `AsyncIterable`,迭代期间的连接/协议/上游错误会终止迭代(抛出),不返回 `HaiResult`。
321
335
  - `segment_started` 携带服务端解析 Provider 后的真实输出参数(`format` / `sampleRate?` / `channels?`);播放器据此解码,不按请求参数猜测格式。
322
336
  - 平台不支持的输入方式(如 OpenAI/MiMo 的持续音频输入)会抛 `AUDIO_UNSUPPORTED_INPUT`,不伪装成实时。
@@ -178,6 +178,7 @@ const result = await ai.rag.query('核心架构是什么?', {
178
178
  | `getCapabilities({ operation, model? })` | `HaiResult<AudioModelCapabilities>` | 按模型与操作查询 `transcribe` / `synthesize` 能力分支,并拒绝操作不匹配模型 |
179
179
 
180
180
  - 音频类型:`AudioContent { data: Uint8Array, format: 'pcm16'|'wav'|'mp3'|'opus', sampleRate?, channels? }`;`pcm16` 等裸音频必须传 `sampleRate`。
181
+ - 播放负载:`serializePlayableAudio(audio)` 返回 `HaiResult<PlayableAudio>`;`PlayableAudio` 为 `{ audioBase64, format: 'wav'|'mp3', mimeType }`。该函数将 `pcm16` 封装为 WAV,透传 WAV/MP3;`opus` 不支持转换。
181
182
  - 请求可选:识别 `contextHints?: string[]`(热词/提示);合成 `instruction?: string`(自然语言风格指令);均支持 `signal: AbortSignal`。
182
183
  - 模型配置:`audio.models: [{ id, provider: 'openai'|'mimo'|'qwen'|'doubao', model, operations, apiKey?, baseUrl?, appKey?, accessKey?, resourceId?, workspaceId? }]`;`operations` 必须明确为识别、合成或两者。确认 LLM 与语音模型共用凭据时,可设置 `audio.inheritLlmApiKey: true`,默认关闭。
183
184
  - 凭据优先级:模型条目 `apiKey` → 显式启用继承后的 LLM `apiKey` → `HAI_AI_AUDIO_<PROVIDER>_API_KEY` 或 `OPENAI_API_KEY` / `MIMO_API_KEY` / `DASHSCOPE_API_KEY` / `VOLC_API_KEY`(豆包旧版控制台额外 `VOLC_APP_KEY` / `VOLC_ACCESS_KEY`)。可选密钥中的 YAML `null`、空字符串和纯空白字符串都会规范化为未配置。
@@ -225,7 +225,7 @@ logging:
225
225
  | 身份认证/授权 | `hai-iam` | iam.init, 登录, 注册, RBAC, Token, Bearer |
226
226
  | AI/LLM/MCP | `hai-ai` | ai.init, LLM, MCP, Agent, 工具调用 |
227
227
  | 公共 API 契约 | `hai-api-contract` | apiContract.create, contract, schema, oRPC, HaiResult |
228
- | API Service 运行时 | `hai-serv` | serv.createApp, Hono, procedures, OpenAPI, docs, requireAuth |
228
+ | API Service 运行时 | `hai-serv` | serv.createApp, Hono, procedures, OpenAPI, docs, route guard |
229
229
  | SvelteKit 集成 | `hai-kit` | kit.createHandle, guard, middleware, validate, response |
230
230
  | UI 组件 | `hai-ui` | 表单, 按钮, 表格, Modal, Toast, 移动端组件 |
231
231
  | typed API 客户端 | `hai-api-client` | apiClient.init, typed client, Bearer, 401 refresh, custom fetch |
@@ -131,6 +131,10 @@ transport 默认保护同源 `/api/*` endpoint 与 SvelteKit `__data.json` 页
131
131
  同时驱动 `hooks.server.ts` 与浏览器端 `kit.client.installBrowserTransport()`:
132
132
 
133
133
  ```yml
134
+ server:
135
+ host: 127.0.0.1
136
+ port: 3000
137
+
134
138
  transport:
135
139
  keyExchangePath: /api/_hai/key-exchange
136
140
  requireEncryption: true
@@ -150,7 +154,8 @@ import rawKitConfig from '../config/_kit.yml?raw'
150
154
  export const appKitConfig = resolveKitConfig(parse(rawKitConfig) ?? {})
151
155
  ```
152
156
 
153
- `_kit.yml` 只放公开的 transport 路径/开关,不放密钥;如自定义 `keyExchangePath`,浏览器端必须同步映射为 `keyExchangeUrl`。
157
+ `resolveKitConfig()` 同时解析 `server`(host/port)与 `transport`;host/port 优先级为 `HOST` / `PORT` 环境变量 > `_kit.yml` 的 `server` > 默认 `127.0.0.1:3000`(与 `@h-ai/serv` 同构)。
158
+ `_kit.yml` 只放公开的 server/transport 路径/开关,不放密钥;如自定义 `keyExchangePath`,浏览器端必须同步映射为 `keyExchangeUrl`。
154
159
 
155
160
  ## 核心 API
156
161
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: hai-serv
3
- description: 使用 @h-ai/serv 将 oRPC contract 挂载为最小 HTTP App 抽象;当需求涉及创建 API 服务、装配 procedure、挂载 OpenAPI 文档、配置健康检查、添加认证/权限 pipeline 包装器或切换 Node/Fetch 运行时适配器时使用。
3
+ description: 使用 @h-ai/serv 将 oRPC contract 挂载为最小 HTTP App 抽象;当需求涉及创建 API 服务、装配 procedure、挂载 OpenAPI 文档、配置健康检查、声明 route 认证授权或切换 Node/Fetch 运行时适配器时使用。
4
4
  ---
5
5
 
6
6
  # hai-serv
@@ -9,7 +9,7 @@ description: 使用 @h-ai/serv 将 oRPC contract 挂载为最小 HTTP App 抽象
9
9
 
10
10
  | 项目 | 契约 |
11
11
  | --- | --- |
12
- | 能力 | 使用 @h-ai/serv 将 oRPC contract 挂载为最小 HTTP App 抽象;当需求涉及创建 API 服务、装配 procedure、挂载 OpenAPI 文档、配置健康检查、添加认证/权限 pipeline 包装器或切换 Node/Fetch 运行时适配器时使用。 |
12
+ | 能力 | 使用 @h-ai/serv 将 oRPC contract 挂载为最小 HTTP App 抽象;当需求涉及创建 API 服务、装配 procedure、挂载 OpenAPI 文档、配置健康检查、声明 route 认证授权或切换 Node/Fetch 运行时适配器时使用。 |
13
13
  | 适用场景 | 当任务与 `hai-serv` 的能力描述匹配,并且需要遵循本 Skill 的流程和边界时 |
14
14
  | 输入 | 模块配置、类型化业务参数、依赖初始化状态和目标运行环境 |
15
15
  | 输出 | 符合模块公共 API 的实现或示例;业务结果使用 HaiResult,并同步必要测试与文档 |
@@ -31,14 +31,14 @@ description: 使用 @h-ai/serv 将 oRPC contract 挂载为最小 HTTP App 抽象
31
31
 
32
32
  - 将 `@h-ai/api-contract` contract 装配成最小 HTTP App 抽象
33
33
  - 配置 `/health`、`/ready`、`/openapi.json`、`/docs` 等系统端点
34
- - 在 procedure 中添加认证(`requireAuth`)或权限(`requirePermission`)检查
34
+ - 在 procedure route 上添加 `.auth()`、`.permission()` 或 `.role()` guard
35
35
  - 切换 Node.js (`@hono/node-server`) 或 Fetch Runtime(Cloudflare Workers / Deno)部署
36
36
  - 自定义 `ServContext`(注入 session、tenant 等请求级数据)
37
37
  - 生成 OpenAPI 3.1 文档供外部工具消费
38
38
  - 通过 `config/_serv.yml` 管理 API 前缀、OpenAPI、docs、health、rpc 等 HTTP 挂载配置
39
39
  - 通过 `serv.createRuntimeSecurityPolicy(...)` 对生产 CORS Origin、原生 WebView Origin、Secure Cookie 与 API 文档暴露执行 fail-closed 策略
40
40
  - 启用传输加密:`serv.createApp({ transport: { crypto } })`
41
- - 高级场景通过 `createApp({ middlewares })` 自定义 HTTP middleware,或通过共享类型自定义 procedure wrapper
41
+ - 高级场景通过 `createApp({ middlewares })` 自定义 HTTP middleware,通过 `createContext` 注入请求级业务字段
42
42
  - 公开只读文件通过 `serv.storageAssets(...)` 统一处理安全 key、MIME 白名单、GET/HEAD、ETag 与缓存头
43
43
  - 为 Audio WebSocket 配置一次性 ticket 校验、IAM/模型/配额授权与并发释放钩子
44
44
 
@@ -86,8 +86,11 @@ const app = serv.createApp({
86
86
  },
87
87
  })
88
88
 
89
- // Node.js 启动(自动读取 PORT / HOST 环境变量;onClose 自动监听 SIGINT/SIGTERM)
89
+ // Node.js 启动(host/port 来自 _serv.yml 的 server;HOST/PORT 环境变量高于配置文件;onClose 自动监听 SIGINT/SIGTERM)
90
+ const { server } = core.config.getOrThrow<ServConfig>('serv')
90
91
  serv.listen(app, {
92
+ host: server.host,
93
+ port: server.port,
91
94
  onListening: info => logger.info('API service listening', { port: info.port }),
92
95
  onClose: closeApp, // 业务模块关闭函数(ai/storage/iam/reldb 等反向释放)
93
96
  })
@@ -179,16 +182,19 @@ import { ok } from '@h-ai/core'
179
182
  import { serv } from '@h-ai/serv'
180
183
 
181
184
  export function createAppProcedures(deps: { name: string; version: string }) {
182
- const p = serv.implement(appContract).$context<ServContext>()
183
- return p.router({
184
- // 公开 procedure:handler 直接返回 HaiResult
185
- info: p.info.handler(() => ok({ name: deps.name, version: deps.version })),
186
- // 鉴权 procedure:用 serv.requireAuth 包一层,未登录时短路返回 ApiUnauthorized
187
- echo: p.echo.handler(serv.requireAuth(({ input, context }) => ok({
185
+ return serv
186
+ .implement(appContract)
187
+ .context<ServContext>()
188
+ // 公开 procedure:route 名只出现一次,handler 直接返回 HaiResult。
189
+ .route('info', () => ok({ name: deps.name, version: deps.version }))
190
+ // 鉴权 procedure:guard 通过后 context.session 自动收窄为非空。
191
+ .route('echo')
192
+ .auth()
193
+ .handle(({ input, context }) => ok({
188
194
  message: input.message,
189
- userId: context.session!.userId,
190
- }))),
191
- })
195
+ userId: context.session.userId,
196
+ }))
197
+ .build()
192
198
  }
193
199
  ```
194
200
 
@@ -215,7 +221,7 @@ const app = serv.createApp({ contract, procedures, http, iam })
215
221
  注意要点:
216
222
 
217
223
  - **无 input 的 POST 过程**:默认不发送 body;transport 加密层会自动跳过空 body 请求,客户端可直接 `client.app.info()`。
218
- - **`serv.requireAuth` 仅可在过程内调用**:必须在 `context` 中有 `session`;公开过程不要包装它。
224
+ - **认证授权写在 route 链上**:公开过程使用 `.route(path, handler)`;需要认证时使用 `.route(path).auth().handle(handler)`。
219
225
  - **客户端导入相同 contract**:把服务端与客户端共享的 contract 独立成 package(如 `@h-ai/api-service-contract`),禁止从 app 源码目录跨应用 import。
220
226
 
221
227
  ### 5. Fetch Runtime(Cloudflare Workers / Deno / Bun)
@@ -262,6 +268,10 @@ const app = serv.createApp({
262
268
 
263
269
  ```yaml
264
270
  # config/_serv.yml
271
+ server:
272
+ host: 127.0.0.1
273
+ port: 3000
274
+
265
275
  http:
266
276
  apiPrefix: /api/v1
267
277
  openapi:
@@ -320,8 +330,14 @@ import { serv } from '@h-ai/serv'
320
330
  // 等价于 oc.route(...),但不让应用感知 @orpc/contract
321
331
  const route = apiContract.route({ method: 'POST', path: '/x', operationId: 'x', tags: ['x'] })
322
332
 
323
- // 等价于 implement(contract)
324
- const p = serv.implement(appContract).$context<ServContext>()
333
+ const procedures = serv
334
+ .implement(appContract)
335
+ .context<ServContext>()
336
+ .route('info', infoHandler)
337
+ .route('echo')
338
+ .auth()
339
+ .handle(echoHandler)
340
+ .build()
325
341
  ```
326
342
 
327
343
  ### 传输加密
@@ -379,16 +395,17 @@ await apiClient.init({
379
395
  3. `iam.session.verifyToken`(推荐:传入 `iam` 后自动启用)
380
396
  4. 默认 `parseRequestContext`:仅解析请求元数据,`session` 为 `undefined`
381
397
 
382
- > ⚠️ `verifyToken` 会在每次请求调用(不缓存);verifyToken 失败或抛错统一收敛为 `session=undefined`,由 `requireAuth` 统一返回 401。
398
+ > ⚠️ `verifyToken` 会在每次请求调用(不缓存);verifyToken 失败或抛错统一收敛为 `session=undefined`,由 route 的 `.auth()` / `.permission()` / `.role()` 统一返回 401。
383
399
 
384
- ### Procedure 包装器(所有导出为 `serv.xxx` 扁平 API)
400
+ ### 链式 procedure guard
385
401
 
386
- | 函数 | 说明 |
402
+ | 链式调用 | 说明 |
387
403
  | --- | --- |
388
- | `serv.mapHaiError(handler)` | 捕获未处理异常,转换为 `HaiResult` |
389
- | `serv.requireAuth(handler)` | 验证 session(`context.session` 非空,即 token 已通过 `verifyToken` 校验) |
390
- | `serv.requirePermission(permission, handler)` | 验证权限码,支持通配符 `serv.WILDCARD_PERMISSION`(`'*'`) |
391
- | `serv.requireRole(role, handler)` | 验证角色,支持通配符 `serv.WILDCARD_ROLE`(`'*'`) |
404
+ | `.route(path, handler)` | 注册公开 procedure;path/input/output 从 contract 推导 |
405
+ | `.route(path).auth().handle(handler)` | 要求 session;通过后 `context.session` 自动收窄 |
406
+ | `.permission(permission)` | 隐含认证并检查权限,支持 `serv.WILDCARD_PERMISSION` |
407
+ | `.role(role)` | 隐含认证并检查角色,支持 `serv.WILDCARD_ROLE` |
408
+ | `.build()` | contract 全部 procedures 实现后构建 router |
392
409
  | `serv.validateInputOrFail(zodSchema, input, locale)` | 在 procedure 内执行 Zod 二次校验,失败时返回本地化 `HaiResult` + `ValidationFormError[]` |
393
410
  | `serv.resolveRequestLocale(headers)` | 从 `x-hai-locale` / `Accept-Language` 解析并规范化 locale |
394
411
  | `serv.m(key, { locale, params })` | 读取 serv 自身 i18n 消息(请求级本地化) |
@@ -396,26 +413,29 @@ await apiClient.init({
396
413
  ```typescript
397
414
  import { serv } from '@h-ai/serv'
398
415
 
399
- // 组合包装(从外到内:error → auth → permission/role → handler)
400
- const handler = serv.mapHaiError(
401
- serv.requireAuth(
402
- serv.requirePermission('user.write', actualHandler)
403
- )
404
- )
405
-
406
- // 角色检查示例
407
- const adminOnly = serv.requireAuth(
408
- serv.requireRole('admin', actualHandler)
409
- )
416
+ const procedures = serv
417
+ .implement(appContract)
418
+ .context<ServContext>()
419
+ .route('info', infoHandler)
420
+ .route('users.update')
421
+ .permission('user.write')
422
+ .role('admin')
423
+ .handle(updateUserHandler)
424
+ .build()
410
425
  ```
411
426
 
412
- ### 高级:自定义 pipeline(HTTP middleware + context + procedure wrapper)
427
+ - `.permission()` / `.role()` 隐含 `.auth()`;未登录返回 401,授权不足返回 403。
428
+ - 同一路由声明多个 permission/role 时必须全部满足。
429
+ - handler 的未处理异常由 router 统一转换成 INTERNAL_ERROR HaiResult。
430
+ - 重复/未知路径会被拒绝;遗漏 contract procedure 时 `.build()` 在类型上不可用,运行时也会校验。
431
+
432
+ ### 高级:自定义 pipeline(HTTP middleware + context + route guard)
413
433
 
414
434
  `@h-ai/serv` 的 pipeline 分三层:
415
435
 
416
436
  1. **HTTP middleware 层**:通过 `serv.createApp({ middlewares })` 注入 HTTP middleware
417
437
  2. **context 层**:通过 `verifyToken` / `createContext` / `serv.buildAuthContextFactory()` 自定义请求上下文
418
- 3. **procedure wrapper 层**:通过 `ServProcedureWrapper` / `ServGuardedProcedureWrapper` 自定义业务包装器
438
+ 3. **route guard 层**:通过 `.auth()` / `.permission()` / `.role()` 声明认证授权
419
439
 
420
440
  #### HTTP middleware
421
441
 
@@ -501,44 +521,25 @@ const app = serv.createApp({
501
521
  - 在默认认证上下文之上追加字段:`buildAuthContextFactory(...) + createContext`
502
522
  - 完全接管上下文构造:`createContext`
503
523
 
504
- #### Procedure wrapper
524
+ #### Route guard
505
525
 
506
- 适用于已进入 procedure 后的审计、租户约束、业务 guard:
526
+ 认证、权限与角色约束直接写在目标 route 上,不嵌套高阶 handler:
507
527
 
508
528
  ```typescript
509
- import type { ServGuardedProcedureWrapper, ServProcedureWrapper } from '@h-ai/serv'
510
- import { err, HaiCommonError } from '@h-ai/core'
511
- import { serv } from '@h-ai/serv'
512
-
513
- const withAudit: ServProcedureWrapper = handler => async (options) => {
514
- options.context.logger.info('procedure.start', { requestId: options.context.requestId })
515
- return await handler(options)
516
- }
517
-
518
- const requireTenant: ServGuardedProcedureWrapper<string> = (tenantId, handler) => async (options) => {
519
- if (options.context.request.headers.get('x-tenant-id') !== tenantId) {
520
- return err(
521
- HaiCommonError.FORBIDDEN,
522
- serv.m('serv_errorForbidden', { locale: options.context.locale }),
523
- )
524
- }
525
- return await handler(options)
526
- }
527
-
528
- const createWidget = serv.mapHaiError(
529
- serv.requireAuth(
530
- withAudit(
531
- requireTenant('tenant-a', async ({ input }) => widgetService.create(input)),
532
- ),
533
- ),
534
- )
529
+ const procedures = serv
530
+ .implement(widgetContract)
531
+ .context<ServContext>()
532
+ .route('widgets.list', ({ input }) => widgetService.list(input))
533
+ .route('widgets.create')
534
+ .permission('widgets.write')
535
+ .handle(({ input, context }) =>
536
+ widgetService.create(context.session.userId, input))
537
+ .build()
535
538
  ```
536
539
 
537
- 说明:
538
-
539
- - `ServMiddleware` / `ServMiddlewareMount` / `ServProcedureWrapper` / `ServGuardedProcedureWrapper` 等共享类型由根入口 `@h-ai/serv` 对外暴露
540
- - `src/pipelines/*` 是模块内部默认实现目录,不作为公开子路径 API 文档承诺
541
- - 常规场景优先继续用 `serv.requireAuth / requirePermission / requireRole / mapHaiError`
540
+ HTTP 审计、限流、CORS、租户头校验等跨 route 能力继续放在
541
+ `createApp({ middlewares })`;请求级业务字段通过 `createContext` 注入。
542
+ 不要为认证授权重新嵌套多层高阶 handler。
542
543
 
543
544
  ### 正常业务请求中的错误与 i18n
544
545
 
@@ -549,22 +550,24 @@ const createWidget = serv.mapHaiError(
549
550
  3. **业务/领域错误**:不要 throw 预期失败;直接返回 `err(...)`,并在**创建错误消息的那一层**用对应模块的 i18n getter 按 `context.locale` 出消息。
550
551
 
551
552
  ```typescript
552
- import { err, HaiCommonError } from '@h-ai/core'
553
553
  import { z } from 'zod'
554
554
  import { serv } from '@h-ai/serv'
555
555
 
556
556
  const Schema = z.object({ title: z.string().min(1) })
557
557
 
558
- const handler = serv.mapHaiError(async ({ input, context }) => {
559
- const validated = serv.validateInputOrFail(Schema, input, context.locale)
560
- if (!validated.success)
561
- return validated
562
-
563
- if (!context.session)
564
- return err(HaiCommonError.UNAUTHORIZED, serv.m('serv_errorUnauthorized', { locale: context.locale }))
565
-
566
- return widgetService.create(validated.data)
567
- })
558
+ const procedures = serv
559
+ .implement(widgetContract)
560
+ .context<ServContext>()
561
+ .route('widgets.create')
562
+ .auth()
563
+ .handle(({ input, context }) => {
564
+ const validated = serv.validateInputOrFail(Schema, input, context.locale)
565
+ if (!validated.success)
566
+ return validated
567
+
568
+ return widgetService.create(context.session.userId, validated.data)
569
+ })
570
+ .build()
568
571
  ```
569
572
 
570
573
  > 如果错误来自下游模块(如 `iam` / `storage`)并且该模块已经返回 `HaiResult`,serv 默认**透传**它的 `error.message`。由于 `HaiError` 目前不携带 `messageKey/params`,serv 边界不能对任意下游错误再做通用重翻译;要做请求级 i18n,必须在创建该错误的模块/feature 里就拿到 locale。
@@ -639,20 +642,17 @@ import type { ServContext } from '@h-ai/serv'
639
642
  import { serv } from '@h-ai/serv'
640
643
  import { myContract } from './my-contract.js'
641
644
 
642
- const { requireAuth, requirePermission } = serv
643
-
644
645
  export function createMyProcedures() {
645
- return {
646
- widget: {
647
- list: requireAuth(async ({ input, context }) => {
648
- // input 类型来自 contract 定义
649
- return ok(await widgetService.list(input))
650
- }),
651
- create: requirePermission('widget:write', async ({ input }) => {
652
- return ok(await widgetService.create(input))
653
- }),
654
- },
655
- }
646
+ return serv
647
+ .implement(myContract)
648
+ .context<ServContext>()
649
+ .route('widget.list')
650
+ .auth()
651
+ .handle(({ input }) => widgetService.list(input))
652
+ .route('widget.create')
653
+ .permission('widget:write')
654
+ .handle(({ input }) => widgetService.create(input))
655
+ .build()
656
656
  }
657
657
  ```
658
658
 
@@ -681,13 +681,13 @@ const app = serv.createApp({
681
681
 
682
682
  ## 错误码
683
683
 
684
- procedure 包装器内置以下错误:
684
+ route 实现器内置以下错误:
685
685
 
686
686
  | 错误码 | 触发条件 |
687
687
  | --- | --- |
688
- | `HaiCommonError.UNAUTHORIZED` | `requireAuth`:`context.session` 为空(无 token 或 token 校验失败) |
689
- | `HaiCommonError.FORBIDDEN` | `requirePermission`:无对应权限 |
690
- | `HaiCommonError.INTERNAL_ERROR` | `mapHaiError`:procedure 抛出未处理异常 |
688
+ | `HaiCommonError.UNAUTHORIZED` | `.auth()`:`context.session` 为空(无 token 或 token 校验失败) |
689
+ | `HaiCommonError.FORBIDDEN` | `.permission()` / `.role()`:无对应权限或角色 |
690
+ | `HaiCommonError.INTERNAL_ERROR` | route handler 抛出未处理异常 |
691
691
 
692
692
  ---
693
693