@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 +2 -2
- package/templates/apps/api/apps/{{projectName}}-service/config/_serv.yml.hbs +4 -0
- package/templates/apps/api/apps/{{projectName}}-service/src/index.ts.hbs +5 -0
- package/templates/apps/api/apps/{{projectName}}-service/src/server/procedures/app-procedures.ts.hbs +18 -14
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_serv.yml.hbs +4 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/index.ts.hbs +7 -7
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/procedures/app-procedures.ts.hbs +8 -8
- package/templates/base/package.json.hbs +1 -1
- package/templates/skills/hai-ai/SKILL.md +15 -1
- package/templates/skills/hai-ai/reference.md +1 -0
- package/templates/skills/hai-build/SKILL.md +1 -1
- package/templates/skills/hai-kit/SKILL.md +6 -1
- package/templates/skills/hai-serv/SKILL.md +98 -98
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.
|
|
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.
|
|
37
|
+
"@h-ai/core": "0.1.0-alpha.48"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@types/fs-extra": "^11.0.4",
|
|
@@ -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
|
})
|
package/templates/apps/api/apps/{{projectName}}-service/src/server/procedures/app-procedures.ts.hbs
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import type { ServContext } from '@h-ai/serv'
|
|
2
|
-
|
|
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
|
|
23
|
-
|
|
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
|
-
|
|
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
|
|
36
|
+
userId: context.session.userId,
|
|
36
37
|
requestId: context.requestId,
|
|
37
38
|
timestamp: new Date().toISOString(),
|
|
38
39
|
}
|
|
39
|
-
return
|
|
40
|
-
})
|
|
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
|
-
})
|
|
48
|
-
|
|
50
|
+
})
|
|
51
|
+
{{/if}}
|
|
52
|
+
.build()
|
|
49
53
|
}
|
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
16
|
-
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
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.
|
|
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,
|
|
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`
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
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
|
-
-
|
|
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
|
-
|
|
324
|
-
|
|
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`,由 `
|
|
398
|
+
> ⚠️ `verifyToken` 会在每次请求调用(不缓存);verifyToken 失败或抛错统一收敛为 `session=undefined`,由 route 的 `.auth()` / `.permission()` / `.role()` 统一返回 401。
|
|
383
399
|
|
|
384
|
-
###
|
|
400
|
+
### 链式 procedure guard
|
|
385
401
|
|
|
386
|
-
|
|
|
402
|
+
| 链式调用 | 说明 |
|
|
387
403
|
| --- | --- |
|
|
388
|
-
|
|
|
389
|
-
|
|
|
390
|
-
|
|
|
391
|
-
|
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
)
|
|
404
|
-
)
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
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. **
|
|
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
|
-
####
|
|
524
|
+
#### Route guard
|
|
505
525
|
|
|
506
|
-
|
|
526
|
+
认证、权限与角色约束直接写在目标 route 上,不嵌套高阶 handler:
|
|
507
527
|
|
|
508
528
|
```typescript
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
-
|
|
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
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
-
|
|
684
|
+
route 实现器内置以下错误:
|
|
685
685
|
|
|
686
686
|
| 错误码 | 触发条件 |
|
|
687
687
|
| --- | --- |
|
|
688
|
-
| `HaiCommonError.UNAUTHORIZED` |
|
|
689
|
-
| `HaiCommonError.FORBIDDEN` | `
|
|
690
|
-
| `HaiCommonError.INTERNAL_ERROR` |
|
|
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
|
|