@h-ai/serv 0.1.0-alpha.56 → 0.1.0-alpha.57

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.ja.md ADDED
@@ -0,0 +1,655 @@
1
+ # @h-ai/serv
2
+
3
+ [简体中文](./README.md) | [English](./README.en.md) | [日本語](./README.ja.md)
4
+
5
+ [フレームワークのホーム](../../README.ja.md) · 関連モジュール:[@h-ai/ai](../ai/README.ja.md) · [@h-ai/api-contract](../api-contract/README.ja.md) · [@h-ai/core](../core/README.ja.md) · [@h-ai/crypto](../crypto/README.ja.md) · [@h-ai/iam](../iam/README.ja.md) · [@h-ai/storage](../storage/README.ja.md)
6
+
7
+ > hai-framework の API サービスランタイム。公開 HTTP App 抽象を通じて `@h-ai/api-contract` を複数プラットフォームから使える HTTP API として提供します。Hono は内部実装です。
8
+
9
+ ## 機能概要
10
+
11
+ - `serv.createApp(...)` → `serv.listen(app, { port })` で起動。アプリは Hono を import する必要がありません。
12
+ - フラットな API:`serv.createApp / serv.implement / serv.listen / serv.toFetch / serv.generateSpec / ...`。
13
+ - procedure のチェーン:`implement().context().route().auth/permission/role().handle().build()`。
14
+ - 独自パイプライン:`createApp({ middlewares })` で HTTP middleware を接続し、context ファクトリーでリクエストごとの値を注入。
15
+ - 読み取り専用 Storage:`serv.storageAssets(...)` で安全な key、MIME 許可リスト、GET/HEAD、ETag、キャッシュヘッダーを管理。
16
+ - 既定の機能 procedures:`createIamProcedures()`、`createStorageProcedures()`、`createAiProcedures()`。
17
+ - 安全な応答ヘッダー・ヘルスチェックを内蔵し、OpenAPI JSON・Scalar 文書・内部 RPC endpoint は任意。
18
+ - 任意の通信暗号化:`serv.createApp({ transport: { crypto } })` で鍵交換とリクエスト・レスポンスの暗号化を自動接続。
19
+ - 任意の音声入口:`serv.createApp({ audio: { ai, verifyTicket, authorize } })` が共通 WebSocket を接続します。接続直後に単回 ticket で認証(`{ session, grant? }`、独立した事前認証 timeout)。各フレームを Zod と厳密な状態機械で検証し、入力・キュー・送信バッファの背圧と連鎖キャンセルを管理します。サーバーの認可処理で操作・モデル・声・割当量・同時使用数を確認します。
20
+
21
+ > ライフサイクル:`@h-ai/serv` は状態を持たない HTTP App の構成機能で、`init()` / `close()` は不要です。app 作成前に `iam` / `storage` / `ai` / `crypto` などを初期化し、`serv.listen(..., { onClose })` で逆順に解放します。`serv.createApp()` は transport 管理器の生成失敗など起動設定のエラーを即座に throw します。
22
+
23
+ ## クイックスタート
24
+
25
+ ```ts
26
+ import { apiContract } from '@h-ai/api-contract'
27
+ import { serv } from '@h-ai/serv'
28
+ import { createAiProcedures } from '@h-ai/serv/features/ai'
29
+ import { createIamProcedures } from '@h-ai/serv/features/iam'
30
+ import { createStorageProcedures } from '@h-ai/serv/features/storage'
31
+
32
+ // 1) 创建契约
33
+ const contract = apiContract.create({ iam: apiContract.iam, storage: apiContract.storage, ai: apiContract.ai })
34
+
35
+ // 2) 组装 procedures(注入 iam / storage / ai 已初始化的 functions)
36
+ const procedures = {
37
+ iam: createIamProcedures({ iam }),
38
+ storage: createStorageProcedures({ storage }),
39
+ ai: createAiProcedures({ ai }),
40
+ }
41
+
42
+ // 3) 创建 HTTP App
43
+ const app = serv.createApp({
44
+ contract,
45
+ procedures,
46
+ http: {
47
+ apiPrefix: '/api/v1',
48
+ openapi: { path: '/openapi.json' },
49
+ docs: { path: '/docs' },
50
+ },
51
+ })
52
+
53
+ // 4) 启动 Node 服务(host/port 来自 _serv.yml 的 server;HOST/PORT 环境变量高于配置文件)
54
+ const { server } = core.config.getOrThrow<ServConfig>('serv')
55
+ serv.listen(app, {
56
+ host: server.host,
57
+ port: server.port,
58
+ onListening: info => logger.info('API service listening', { port: info.port }),
59
+ onClose: closeApp,
60
+ })
61
+
62
+ // 5) 优雅关闭由 serv 托管,无需手动注册 process.once('SIGINT', ...)
63
+ ```
64
+
65
+ ### host / port 設定
66
+
67
+ host/port は `config/_serv.yml` の `server` で指定し、`HOST` / `PORT` 環境変数 > 設定ファイル > `127.0.0.1:3000` の順です。既定の `127.0.0.1` はローカル専用で、コンテナー外に公開する場合は `0.0.0.0` にします:
68
+
69
+ ```yaml
70
+ # config/_serv.yml
71
+ server:
72
+ host: 127.0.0.1
73
+ port: 3000
74
+ ```
75
+
76
+ 解決済みの `server` を `serv.listen` に渡します。`host` / `port` の明示的な指定で上書きすることもできます。
77
+
78
+ ### Fetch Runtime(Workers / Bun / Deno)
79
+
80
+ ```ts
81
+ const handler = serv.toFetch(app)
82
+ export default { fetch: handler }
83
+ ```
84
+
85
+ ### Audio WebSocket:ticket とサーバー認可
86
+
87
+ 音声接続には短期・単回 ticket が必要です。認証済み HTTP endpoint で発行(推奨:`iam.ticket.issue`)し、`verifyTicket` でアトミックに消費します(推奨:`iam.ticket.consume`)。接続直後に独立 timeout で認証し、認証前に業務フレームを処理しません。返された `ServSession` と任意の `grant` を保存してから `authorize` で有料パラメーターを確認します:
88
+
89
+ ```ts
90
+ const app = serv.createApp({
91
+ contract,
92
+ procedures,
93
+ iam,
94
+ audio: {
95
+ ai,
96
+ verifyTicket: consumeAudioTicket, // → HaiResult<{ session, grant? }>
97
+ authorize: async (session, request, grant) => authorizeAudioRequest(session, request, grant),
98
+ onSessionEnd: async (session, request) => releaseAudioConcurrency(session.userId, request.operation),
99
+ },
100
+ })
101
+ ```
102
+
103
+ - `verifyTicket(ticket)` は用途・期限を確認し、1 回のみ成功させ、`HaiResult<AudioTicketVerification>`(`{ session, grant? }`)を返します。`grant` は ticket に固定した操作・モデル・セッションを保持。
104
+ - `authorize(session, request, grant)` で IAM 権限、モデル・声の許可リスト、Persona、割当量、同時実行枠を確認し、`grant` と `start` の整合性を検証します。返された `AuthorizedAudioRequest` のみを `ai.audio` に渡します。
105
+ - `authorize` がなければクライアントの `model`・`voice`・`instruction` は無視し、サーバー既定値(または `grant.model`)を使用します。`grant.operation` と異なる `start` は拒否。
106
+ - 各フレームを Zod と厳密な状態機械で検証します。認識は音声、合成はテキストのみ、`start`/`done` は各 1 回、`segmentId` はセッション内で一意です。入力・滞留メッセージ・送信バッファを制限し、超過・キャンセル時はドメインエラーで閉じて上流も中止します。
107
+ - 調整項目:`preAuthTimeoutMs`(既定 5000)、`maxPendingMessages`(256)、`maxSendBufferBytes`(8 MiB)、`maxMessageBytes` / `maxBufferedBytes` / `maxTextBytes` / `maxSessionMs`。
108
+ - `onSessionEnd` は成功・失敗・切断後に最大 1 回呼び、同時実行枠の解放に使用。
109
+ - ブラウザーの `createAIClient({ audio: { url, getTicket } })` は単回 `ticket` のみを URL に付けます。`getTicket(request)` は `operation` / `model` の概要から今回の操作専用 ticket を発行できます。通常の IAM access token は渡さないでください。
110
+
111
+ ### 通信暗号化(`@h-ai/crypto` と共通)
112
+
113
+ serv 独自の暗号化ファクトリーはなく、`crypto.transport` から管理器を作成します。利用側はトップレベルの `crypto` のみを渡します:
114
+
115
+ ```ts
116
+ import { cache } from '@h-ai/cache'
117
+ import { createRedisTransportKeyStore, crypto } from '@h-ai/crypto'
118
+ import { serv } from '@h-ai/serv'
119
+
120
+ await crypto.init()
121
+ await cache.init({ type: 'redis', host: '127.0.0.1', port: 6379 })
122
+
123
+ const app = serv.createApp({
124
+ contract,
125
+ procedures,
126
+ http: { apiPrefix: '/api/v1' },
127
+ transport: {
128
+ crypto,
129
+ // keyExchangePath 默认 '/_hai/key-exchange',最终端点为 '/api/v1/_hai/key-exchange'
130
+ // excludePaths: ['/health'],
131
+ // maxClients: 10000,
132
+ keyStore: createRedisTransportKeyStore({ cache, ttlSeconds: 3600 }),
133
+ },
134
+ })
135
+ ```
136
+
137
+ クライアントは `@h-ai/api-client` を使います:
138
+
139
+ ```ts
140
+ import { apiClient } from '@h-ai/api-client'
141
+
142
+ await apiClient.init({
143
+ baseUrl: 'https://api.example.com/api/v1',
144
+ transport: { crypto },
145
+ })
146
+ ```
147
+
148
+ `@h-ai/core` で `config/_serv.yml` と `ServConfigSchema` を使う場合、transport もまとめて設定できます:
149
+
150
+ ```yaml
151
+ # config/_serv.yml
152
+ http:
153
+ apiPrefix: /api/v1
154
+
155
+ transport:
156
+ keyExchangePath: /_hai/key-exchange
157
+ excludePaths:
158
+ - /health
159
+ - /ready
160
+ - /openapi.json
161
+ - /docs
162
+ - /_hai/scalar.js
163
+ maxClients: 10000
164
+ ```
165
+
166
+ ```ts
167
+ const servConfig = core.config.getOrThrow<import('@h-ai/serv').ServConfig>('serv')
168
+
169
+ const app = serv.createApp({
170
+ contract,
171
+ procedures,
172
+ http: servConfig.http,
173
+ middlewares: [{
174
+ middleware: serv.cors({
175
+ origin: servConfig.cors.origin,
176
+ credentials: servConfig.cors.credentials,
177
+ allowedHeaders: [...servConfig.cors.allowedHeaders],
178
+ exposedHeaders: [...servConfig.cors.exposedHeaders],
179
+ }),
180
+ }],
181
+ transport: servConfig.transport === false
182
+ ? undefined
183
+ : {
184
+ crypto,
185
+ keyExchangePath: servConfig.transport.keyExchangePath,
186
+ excludePaths: [...servConfig.transport.excludePaths],
187
+ maxClients: servConfig.transport.maxClients,
188
+ },
189
+ })
190
+ ```
191
+
192
+ > **複数ノード**:通信セッション鍵は既定で**サーバープロセス内**に保存します。複数レプリカでは次のいずれかが必要です:
193
+ >
194
+ > - ロードバランサーで **sticky session** を有効化し、同じクライアントを同じノードへ接続。
195
+ > - 各クライアントが各ノードへの初回接続時に個別に鍵交換。
196
+ > - `createRedisTransportKeyStore()` / `createReldbTransportKeyStore()` などの共有 `keyStore` を注入。
197
+ >
198
+ > `keyStore` は実行時オブジェクトの依存で、`config/_serv.yml` に保存しません。設定にはパス・許可リスト・容量などの静的項目のみを記載します。
199
+
200
+ > **安全方針**:`transport` 有効時は `excludePaths` と鍵交換以外で有効な `X-Client-Id` が必要です。不足・未知の clientId は 400、暗号化不能・非 JSON 応答・上限超過はエラーになり、平文の業務データは渡しません。
201
+
202
+ ### Token 認証(`iam` / `refreshCookie`)
203
+
204
+ `serv.createApp` にトップレベルの `iam` を渡すと、次を自動構成します:
205
+
206
+ - **access token 検証**:各リクエストで `iam.session.verifyToken` を呼び `context.session` を設定(キャッシュなし)。
207
+ - **refresh token の更新**:`refreshCookie` 有効時、`iam.session.refresh` を httpOnly cookie 更新処理に使用。
208
+
209
+ **context の優先順位**:明示的 `createContext` > `verifyToken` > `iam.session.verifyToken` > メタデータのみの既定解析。
210
+
211
+ ```ts
212
+ // 推荐:传入 iam,一行打开认证与 Cookie 刷新
213
+ const app = serv.createApp({
214
+ contract,
215
+ procedures,
216
+ iam,
217
+ refreshCookie: {}, // 可选:启用 httpOnly cookie 刷新路径
218
+ })
219
+
220
+ // 逃脱口 1:不使用 @h-ai/iam,自定义 verifyToken
221
+ const app = serv.createApp({
222
+ contract,
223
+ procedures,
224
+ verifyToken: token => myAuthService.verify(token),
225
+ })
226
+
227
+ // 逃脱口 2(高级):多租户等额外字段 — 完全接管上下文构造
228
+ const app = serv.createApp({
229
+ contract,
230
+ procedures,
231
+ createContext: async ({ request }) => {
232
+ const base = serv.parseRequestContext({ request })
233
+ // 手动填充 session(serv 不再自动填充)
234
+ const result = await iam.session.verifyToken(base.accessToken ?? '')
235
+ return {
236
+ ...base,
237
+ session: result.success ? result.data : undefined,
238
+ tenantId: request.headers.get('x-tenant') ?? null,
239
+ }
240
+ },
241
+ })
242
+ ```
243
+
244
+ ### 独自 procedure(認証 + 権限)
245
+
246
+ ```ts
247
+ const procedures = serv
248
+ .implement(myContract)
249
+ .context<ServContext>()
250
+ .route('users.update')
251
+ .permission('users.write')
252
+ .handle(async ({ input, context }) => {
253
+ return await userService.update(input.id, input)
254
+ })
255
+ .build()
256
+ ```
257
+
258
+ ### 高度なパイプライン(HTTP middleware + context + route guard)
259
+
260
+ `@h-ai/serv` のパイプラインは 3 層で、それぞれの拡張入口があります:
261
+
262
+ 1. **HTTP middleware 層**:`serv.createApp({ middlewares })` で注入。
263
+ 2. **context 層**:`verifyToken` / `createContext` / `serv.buildAuthContextFactory()` で拡張。
264
+ 3. **route guard 層**:`.auth()` / `.permission()` / `.role()` で認証・認可を宣言。
265
+
266
+ #### 1) 独自 HTTP middleware
267
+
268
+ ログ・trace・レート制限・CORS・テナントヘッダーなどの **HTTP 共通処理**、または JSON/oRPC contract に適さない WebSocket・ファイル・バイナリー endpoint には `middlewares` を使います。一般的な CORS は `serv.cors(...)` を再利用できます:
269
+
270
+ ```ts
271
+ import type { ServMiddleware } from '@h-ai/serv'
272
+ import { serv } from '@h-ai/serv'
273
+
274
+ const requestMetrics: ServMiddleware = async (c, next) => {
275
+ const startedAt = Date.now()
276
+ await next()
277
+ c.header('x-response-time-ms', String(Date.now() - startedAt))
278
+ }
279
+
280
+ const app = serv.createApp({
281
+ contract,
282
+ procedures,
283
+ http: { apiPrefix: '/api/v1' },
284
+ middlewares: [
285
+ {
286
+ middleware: serv.cors({
287
+ origin: origin => origin === 'https://app.example.com',
288
+ credentials: true,
289
+ exposedHeaders: ['X-Encrypted', 'X-Request-Id'],
290
+ }),
291
+ },
292
+ { middleware: requestMetrics }, // 默认挂到 '*'
293
+ {
294
+ path: '/api/v1/*',
295
+ middleware: async (c, next) => {
296
+ if (!c.req.header('x-tenant-id'))
297
+ return c.text('Missing x-tenant-id', 400)
298
+ await next()
299
+ },
300
+ },
301
+ {
302
+ path: '/assets/*',
303
+ middleware: serv.storageAssets({
304
+ storage,
305
+ pathPrefix: '/assets/',
306
+ keyPattern: /^public\/[\w-]+\.png$/,
307
+ allowedContentTypes: ['image/png'],
308
+ cacheControl: 'public, max-age=31536000, immutable',
309
+ crossOriginResourcePolicy: 'cross-origin',
310
+ }),
311
+ },
312
+ ],
313
+ })
314
+ ```
315
+
316
+ 実行順は固定です:
317
+
318
+ 1. 組み込み `securityHeaders`。
319
+ 2. 独自の `middlewares`。
320
+ 3. 組み込み `transport`(有効時)。
321
+ 4. health / refresh-cookie / OpenAPI / RPC / docs / oRPC routes
322
+
323
+ 補足:
324
+
325
+ - `middlewares` は配列順に登録。
326
+ - `path` 省略時は `'*'`。
327
+ - middleware は transport より先なので、CORS preflight・バイナリー endpoint は途中で応答できます。安全ヘッダーは残りますが transport は通りません。
328
+ - 公開読み取り専用ファイルは `serv.storageAssets(...)` を優先。key/MIME 許可リスト・キャッシュ・CORS はアプリが指定し、アバターなどの業務概念は内蔵しません。
329
+ - 非公開リソース・Range ダウンロード・動的認可は独自 middleware/procedure を使い、公開 helper は使いません。
330
+ - 復号済みの業務 body を読む場合は context / procedure 層を拡張。
331
+ - ブラウザーが `X-Encrypted` などを読む場合は `serv.cors({ exposedHeaders: [...] })` で明示的に公開。
332
+ - この層は HTTP middleware context を操作し、`HaiResult` ではなく **HTTP Response** を返します。
333
+
334
+ #### 2) 独自 context パイプライン
335
+
336
+ `Bearer token -> session` を再利用し、テナント・workspace・送信元などの項目を追加する場合は、
337
+ `serv.buildAuthContextFactory()` で独自の `createContext` を構成してください:
338
+
339
+ ```ts
340
+ const baseContext = serv.buildAuthContextFactory(token => iam.session.verifyToken(token))
341
+
342
+ const app = serv.createApp({
343
+ contract,
344
+ procedures,
345
+ createContext: async ({ request }) => {
346
+ const context = await baseContext({ request })
347
+ return {
348
+ ...context,
349
+ tenantId: request.headers.get('x-tenant-id') ?? null,
350
+ }
351
+ },
352
+ })
353
+ ```
354
+
355
+ 拡張方法の選び方:
356
+
357
+ - Token 検証のみを変更:`verifyToken`。
358
+ - 既定の認証 context に項目を追加:`buildAuthContextFactory(...) + createContext`。
359
+ - context 生成全体を置換:直接 `createContext`。
360
+
361
+ #### 3) Route guard
362
+
363
+ 公開 procedure は `.route(path, handler)` を使い、ガードが必要な場合は次を使います:
364
+ `.route(path).auth/permission/role().handle(handler)`:
365
+
366
+ ```ts
367
+ const procedures = serv
368
+ .implement(widgetContract)
369
+ .context<ServContext>()
370
+ .route('widgets.list', ({ input }) => widgetService.list(input))
371
+ .route('widgets.create')
372
+ .permission('widgets.write')
373
+ .role('editor')
374
+ .handle(({ input, context }) => {
375
+ // guard 通过后 session 在类型上已是非空,不需要 `!`。
376
+ return widgetService.create(context.session.userId, input)
377
+ })
378
+ .build()
379
+ ```
380
+
381
+ 制約:
382
+
383
+ - `.permission()` と `.role()` は認証も要求します。失敗時の HaiResult の `error.httpStatus` は未認証で 401、権限不足で 403 ですが、通常の procedure の HTTP 応答は既定で 200 です。
384
+ - 同じルートの複数 permission/role はすべて満たす必要があります。`serv.WILDCARD_PERMISSION` / `serv.WILDCARD_ROLE` は各分類を許可。
385
+ - 未処理の handler 例外は INTERNAL_ERROR の HaiResult に変換。予期する業務失敗は直接 `err(...)` を返します。
386
+ - path・input・output は contract から推論。重複・未知のパスを拒否し、完全に実装するまで `.build()` は呼べません。
387
+
388
+ 公開 API の境界:
389
+
390
+ - `src/pipelines/*` は内部の既定実装で、公開サブパス API ではありません。
391
+ - アプリ向けの安定した入口はルートの `@h-ai/serv`。
392
+ - `securityHeaders` / `requireInternalRPC` / `buildHaiErrorBody` などの内部 helper は独立サブパスとして公開しません。
393
+
394
+ ### httpOnly Cookie 認証(本番推奨)
395
+
396
+ httpOnly cookie モードは refresh token をサーバー管理の Cookie に保存し、ブラウザー JS から読めなくして XSS による漏洩を抑えます。
397
+
398
+ 2 種類の Token の役割:
399
+
400
+ - `accessToken`:短期認証情報。クライアントのメモリーに保持し、`Authorization: Bearer <token>` で送信。
401
+ `serv.parseRequestContext()` / `extractBearerToken()` が読み取り、`buildAuthContextFactory()` が検証します。
402
+ - `refreshToken`:長期認証情報。`refreshCookie` 有効時は JS に公開せず、httpOnly cookie にのみ保存。
403
+ `extractBearerToken()` はこれを**読みません**。サーバーの `/auth/refresh` のみが読み、新しい access token を発行します。
404
+ 信頼されたネイティブクライアントは `nativeTokenTransport` で JSON body を使用でき、クライアント識別と Origin の両方を検証します。
405
+
406
+ **処理の流れ:**
407
+
408
+ 1. ブラウザーログイン:サーバーが `Set-Cookie: hai_refresh_token=...;HttpOnly` を設定し、JSON から `refreshToken` を除去。
409
+ 2. access token をクライアントのメモリーに保持し、永続化しません。
410
+ 3. access token の期限切れで、ブラウザーが Cookie 付きで `/auth/refresh` を呼びます。
411
+ 4. サーバーが Cookie を読み `onRefresh` を呼び、新 access token と更新 Cookie を返します。本文に refresh token は公開しません。
412
+
413
+ `nativeTokenTransport.isRequest` に一致するネイティブクライアントでは、ログイン・登録・更新の応答に更新後の
414
+ `refreshToken` を残します。更新リクエストは body の `{ refreshToken }` を読み、全体でブラウザー Cookie を使いません。
415
+
416
+ **サーバー設定:**
417
+
418
+ ```ts
419
+ // 服务端:api-service/src/app.ts
420
+ const app = serv.createApp({
421
+ contract,
422
+ procedures,
423
+ http: { apiPrefix: '/api/v1' },
424
+ iam, // 顶层 iam 同时驱动 verifyToken 与 refresh
425
+ refreshCookie: {
426
+ // cookieName: 'hai_refresh_token', // 默认
427
+ // maxAge: 30 * 24 * 3600, // 默认 30 天
428
+ // secure: true, // 默认在 NODE_ENV=production 时开启
429
+ // onRefresh: customFn, // 可覆盖 iam.session.refresh。
430
+ nativeTokenTransport: {
431
+ // 必须 fail-closed:同时校验平台标识与允许的原生 Origin。
432
+ isRequest: request =>
433
+ request.headers.get('x-client-platform') === 'capacitor'
434
+ && security.isNativeOrigin(request.headers.get('origin') ?? ''),
435
+ },
436
+ },
437
+ // ✅ 无需再传 verifyToken,iam.session.verifyToken 自动用于填充 context.session
438
+ })
439
+ ```
440
+
441
+ **クライアント設定(`@h-ai/api-client` と連携):**
442
+
443
+ ```ts
444
+ // 默认存储即 httpOnly cookie,refreshPath 默认 /auth/refresh,无需额外配置。
445
+ await apiClient.init({
446
+ baseUrl: 'https://api.example.com/api/v1',
447
+ auth: {},
448
+ })
449
+ ```
450
+
451
+ > **Cookie 属性**:本番は `HttpOnly; SameSite=Strict; Secure`。`Path` は `{apiPrefix}/auth/refresh` に限定。
452
+
453
+ ## API 一覧
454
+
455
+ - `serv.createApp(options)`:独自 middleware、ヘルスチェック、OpenAPI、任意の文書・RPC を備えた HTTP App を作成。
456
+ - `serv.parseRequestContext({ request })`:Bearer token + requestId を抽出し、session は設定しません。
457
+ - `serv.listen(app, options)`:Node.js HTTP サービスを起動し、`{ server, address, close }` を返す。
458
+ - `serv.toFetch(app)`:標準の `fetch(Request)` handler に変換。
459
+ - `serv.generateSpec(contract, options)`:contract から OpenAPI 3.1 spec を生成。
460
+ - `serv.storageAssets(config)`:key/MIME 許可リスト、GET/HEAD、ETag 付きの読み取り専用 Storage middleware。
461
+ - `serv.implement(contract)`:チェーン形式の contract 実装器を作成。
462
+ - `.context<ServContext>()`:procedure のリクエスト context を宣言。
463
+ - `.route(path, handler)`:公開 procedure を登録。
464
+ - `.route(path).auth/permission/role().handle(handler)`:認証・認可付き procedure を登録。
465
+ - `.build()`:全 contract procedure の実装後に router を構築。
466
+ - `serv.validateInputOrFail(zodSchema, input, locale)`:procedure 内の追加 Zod 検証。失敗時はローカライズ済み `HaiResult` + `ValidationFormError[]`。
467
+ - `serv.resolveRequestLocale(headers)`:`x-hai-locale` / `Accept-Language` から locale を取得・正規化。
468
+ - `serv.m(key, options)`:本モジュールのメッセージを読み、`options.locale` で呼び出しごとにローカライズ。
469
+
470
+ その他のルート型・名前付きエクスポート:
471
+
472
+ - `ServConfigSchema`:`core.config.validate('serv', ServConfigSchema)` で `config/_serv.yml` を検証。
473
+ - HTTP App 抽象:`serv.createApp()` は公開 `fetch/request` 機能を持つオブジェクトを返し、内部の Hono 使用はアプリに影響しません。
474
+ - `ServMiddlewareMount`:`createApp({ middlewares })` の要素型(`{ path?, middleware }`)。
475
+ - `ServMiddleware` / `ServProcedureHandler`:独自パイプライン用の共通型。
476
+ - `NativeRefreshTokenTransportConfig` / `ServStorageAssetsConfig`:ネイティブ Token 経路・読み取り専用リソース設定。
477
+
478
+ > 通信暗号化は `createApp` の設定機能で、内部で `crypto.transport` に委譲します。`serv.xxx` の独立 API ではありません。
479
+
480
+ ## 設定
481
+
482
+ `@h-ai/serv` 自体は YAML を走査しません。`@h-ai/core` で `config/_serv.yml` を読み、解析結果を `serv.createApp()` に渡してください。
483
+
484
+ ```ts
485
+ import { core } from '@h-ai/core'
486
+ import { serv, ServConfigSchema } from '@h-ai/serv'
487
+
488
+ core.init({ configDir: './config' })
489
+
490
+ const validation = core.config.validate('serv', ServConfigSchema)
491
+ if (!validation.success)
492
+ throw new Error(validation.error.message)
493
+
494
+ const servConfig = core.config.getOrThrow<import('@h-ai/serv').ServConfig>('serv')
495
+
496
+ const app = serv.createApp({
497
+ contract,
498
+ procedures,
499
+ http: servConfig.http,
500
+ transport: servConfig.transport === false
501
+ ? undefined
502
+ : {
503
+ crypto,
504
+ keyExchangePath: servConfig.transport.keyExchangePath,
505
+ excludePaths: [...servConfig.transport.excludePaths],
506
+ maxClients: servConfig.transport.maxClients,
507
+ },
508
+ })
509
+ ```
510
+
511
+ `config/_serv.yml` の例:
512
+
513
+ ```yaml
514
+ server:
515
+ host: 127.0.0.1
516
+ port: 3000
517
+
518
+ http:
519
+ apiPrefix: /api/v1
520
+ openapi:
521
+ path: /openapi.json
522
+ docs:
523
+ path: /docs
524
+ health:
525
+ path: /health
526
+ readyPath: /ready
527
+ rpc: false
528
+
529
+ cors:
530
+ origin: https://app.example.com
531
+ nativeOrigins: capacitor://app.example.local,tauri://app.example.local
532
+ credentials: true
533
+ allowedHeaders:
534
+ - Authorization
535
+ - Content-Type
536
+ - X-Client-Id
537
+ exposedHeaders:
538
+ - X-Encrypted
539
+
540
+ transport:
541
+ keyExchangePath: /_hai/key-exchange
542
+ excludePaths:
543
+ - /health
544
+ - /ready
545
+ - /openapi.json
546
+ - /docs
547
+ - /_hai/scalar.js
548
+ maxClients: 10000
549
+ ```
550
+
551
+ `server`(host/port):`HOST` / `PORT` 環境変数 > 設定ファイル > `127.0.0.1:3000`。アプリが `servConfig.server` を `serv.listen` に渡します。
552
+
553
+ `ServHttpConfigInput`:
554
+
555
+ - `apiPrefix`:既定 `/api/v1`。
556
+ - `health`:既定 `{ path: '/health', readyPath: '/ready' }`。
557
+ - `openapi`:既定 `false`。`{ path: '/openapi.json' }` で有効化。
558
+ - `docs`:既定 `false`。`{ path: '/docs' }` で有効化(`openapi` が必要)。`@scalar/api-reference` の browser bundle から `/_hai/scalar.js` を提供し、外部 CDN なしで Scalar UI を使えます。
559
+ - `rpc`:既定 `false`。`{ prefix: '/rpc', access: 'loopback' | 'private-network' | 'gateway-only', gatewayHeader?, gatewaySecret? }` で有効化。
560
+ - `cors`:origin・credentials・allowedHeaders・exposedHeaders を設定し、`serv.cors(...)` で接続。
561
+ - `transport`:`config/_serv.yml` のトップレベル設定。`servConfig.transport` を `createApp({ transport })` に渡し、既定の鍵交換サブパスは `/_hai/key-exchange`。
562
+
563
+ 本番では `serv.createRuntimeSecurityPolicy(...)` で CORS・Cookie・文書の公開方針を制限してください:
564
+
565
+ ```ts
566
+ const security = serv.createRuntimeSecurityPolicy({
567
+ environment: coreConfig.env,
568
+ corsOrigin: servConfig.cors.origin,
569
+ nativeOrigins: servConfig.cors.nativeOrigins,
570
+ })
571
+
572
+ const app = serv.createApp({
573
+ contract,
574
+ procedures,
575
+ http: {
576
+ ...servConfig.http,
577
+ openapi: security.exposeApiDocs ? servConfig.http.openapi : false,
578
+ docs: security.exposeApiDocs ? servConfig.http.docs : false,
579
+ },
580
+ refreshCookie: { secure: security.secureRefreshCookie },
581
+ middlewares: [{
582
+ middleware: serv.cors({
583
+ origin: security.allowOrigin,
584
+ credentials: servConfig.cors.credentials,
585
+ allowedHeaders: [...servConfig.cors.allowedHeaders],
586
+ exposedHeaders: [...servConfig.cors.exposedHeaders],
587
+ }),
588
+ }],
589
+ })
590
+ ```
591
+
592
+ 本番では `_serv.yml` の `cors.origin` が空・`*` なら起動に失敗します。Web・Capacitor/Tauri は正規化済み Origin 全体を厳密照合し、末尾偽装を防ぎます。本番は Secure refresh cookie を強制し OpenAPI/docs を無効化します。非本番の endpoint は設定ファイルで決めます。
593
+
594
+ ## エラー処理
595
+
596
+ 通常の procedure は認証・認可・業務の失敗と捕捉した handler 例外を HaiResult として既定で HTTP 200 で返します。`error.httpStatus` はドメインエラーのメタデータで、HTTP 応答ステータスを変更しません。クライアントは `success` と `error.code` を確認してください。未認証では `error.httpStatus=401`、DB クエリー失敗や INTERNAL_ERROR では 500 です。
597
+
598
+ oRPC contract の入力検証は procedure の前に失敗すると HTTP 400 となり、ローカライズ済み body に `error.httpStatus=400` とフィールドエラーを含みます。procedure 内の `serv.validateInputOrFail` の失敗は HTTP 200 のままです。HTTP middleware、refresh-cookie、OpenAPI/RPC のアクセス確認、transport のプロトコルエラーは各自の HTTP 応答契約に従います。api-client の自動更新は実際の HTTP 401 のみで動作し、HTTP 200 の包絡中の `error.httpStatus=401` では動作しません。
599
+
600
+ - 既定 procedures はすべて `HaiResult<T>` を返し、クライアントで `result.success` を確認。
601
+ - 認証失敗 → `HaiCommonError.UNAUTHORIZED`。`.auth()` が `context.locale` でローカライズ。
602
+ - 認可失敗 → `HaiCommonError.FORBIDDEN`。`.permission()` / `.role()` が `context.locale` でローカライズ。
603
+ - 未捕捉例外 → `HaiCommonError.INTERNAL_ERROR`。route 実装器が処理して `context.locale` でローカライズ。
604
+
605
+ 通常の業務リクエストは 3 層で処理します:
606
+
607
+ 1. **oRPC contract 入力検証**:procedure 前に実行。`serv.createApp()` が Zod の既定英語エラーを捕捉し、ローカライズ済み `errors[]` に変換。
608
+ 2. **procedure 内の追加検証**:DB から読む値・複数フィールド制約など Zod で表せる規則は `serv.validateInputOrFail(zodSchema, input, context.locale)` を使います。単純な業務規則は `err(...)` を返し、統一のためだけの Schema は不要です。
609
+ 3. **業務・ドメインエラー**:予期する失敗は throw せず `err(...)` を返します。**メッセージの生成層**で `serv.m(..., { locale: context.locale })` や `widgetM(..., { locale: context.locale })` など対応モジュールの getter を使います。
610
+
611
+ ```ts
612
+ import { err, HaiCommonError } from '@h-ai/core'
613
+ import { serv } from '@h-ai/serv'
614
+ import { z } from 'zod'
615
+
616
+ const CreateWidgetSchema = z.object({
617
+ title: z.string().min(1),
618
+ slug: z.string().min(3),
619
+ })
620
+
621
+ const procedures = serv
622
+ .implement(widgetContract)
623
+ .context<ServContext>()
624
+ .route('widgets.create')
625
+ .permission('widgets.write')
626
+ .handle(({ input, context }) => {
627
+ const validated = serv.validateInputOrFail(CreateWidgetSchema, input, context.locale)
628
+ if (!validated.success)
629
+ return validated
630
+
631
+ if (validated.data.slug === 'admin') {
632
+ return err(
633
+ HaiCommonError.FORBIDDEN,
634
+ serv.m('serv_errorForbidden', { locale: context.locale }),
635
+ )
636
+ }
637
+
638
+ return widgetService.create(validated.data)
639
+ })
640
+ .build()
641
+ ```
642
+
643
+ > `iam.user.xxx()` など下流が直接 `HaiResult` を返す場合、serv は `error.message` をそのまま渡します。`HaiError` は最終 `message` のみで `messageKey/params` を持たないため、**serv の境界で任意の下流エラーを汎用的に再翻訳することはできません**。リクエストごとの i18n はエラーを生成するモジュールで locale を受け取る必要があります。
644
+
645
+ ## テスト
646
+
647
+ ```bash
648
+ pnpm --filter @h-ai/serv test
649
+ pnpm --filter @h-ai/serv typecheck
650
+ pnpm --filter @h-ai/serv lint
651
+ ```
652
+
653
+ ## License
654
+
655
+ Apache-2.0
package/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # @h-ai/serv
2
2
 
3
+ [简体中文](./README.md) | [English](./README.en.md) | [日本語](./README.ja.md)
4
+
5
+ [框架首页](../../README.md) · 相关模块:[@h-ai/ai](../ai/README.md) · [@h-ai/api-contract](../api-contract/README.md) · [@h-ai/core](../core/README.md) · [@h-ai/crypto](../crypto/README.md) · [@h-ai/iam](../iam/README.md) · [@h-ai/storage](../storage/README.md)
6
+
3
7
  > hai-framework 的 API 服务运行时:对外暴露稳定的 HTTP App 抽象,把 `@h-ai/api-contract` 契约挂载为跨端可访问的 HTTP API;Hono 仅作为内部运行时实现。
4
8
 
5
9
  ## 能力概览
@@ -376,7 +380,7 @@ const procedures = serv
376
380
 
377
381
  约束:
378
382
 
379
- - `.permission()` 与 `.role()` 自动要求认证;未登录返回 401,授权不足返回 403。
383
+ - `.permission()` 与 `.role()` 自动要求认证;未登录/授权不足返回失败 HaiResult,`error.httpStatus` 分别为 401/403;普通 procedure 的 HTTP 响应默认仍为 200。
380
384
  - 同一路由声明多个 permission/role 时必须全部满足;`serv.WILDCARD_PERMISSION` / `serv.WILDCARD_ROLE` 可放行对应类别。
381
385
  - handler 抛出的未处理异常统一转成 INTERNAL_ERROR HaiResult;预期业务失败仍应直接返回 `err(...)`。
382
386
  - 路径、input、output 从 contract 推导;重复或未知路径会被拒绝,未实现完整 contract 时不能调用 `.build()`。
@@ -589,6 +593,10 @@ const app = serv.createApp({
589
593
 
590
594
  ## 错误处理
591
595
 
596
+ 普通 procedure 的认证、授权、业务失败和被捕获的 handler 异常都作为 `HaiResult` 输出,默认通过 HTTP 200 返回;`error.httpStatus` 是领域错误元数据,不会自动改写响应状态。客户端必须检查 `success` 与 `error.code`。例如未登录为 `error.httpStatus=401`,数据库查询失败或 INTERNAL_ERROR 为 500。
597
+
598
+ oRPC contract 输入校验在 procedure 之前失败时返回 HTTP 400,本地化失败体包含 `error.httpStatus=400` 和字段错误;procedure 内 `serv.validateInputOrFail` 返回的失败仍为 HTTP 200。HTTP middleware、refresh-cookie、OpenAPI/RPC 权限与 transport 协议错误另按自身契约返回真实 HTTP 状态。api-client 自动刷新只响应真实 HTTP 401,不因 HTTP 200 包络中的 `error.httpStatus=401` 触发。
599
+
592
600
  - Default procedures 全部返回 `HaiResult<T>`,客户端直接判断 `result.success`
593
601
  - 认证失败 → `HaiCommonError.UNAUTHORIZED`(由 `.auth()` 按 `context.locale` 本地化)
594
602
  - 授权失败 → `HaiCommonError.FORBIDDEN`(由 `.permission()` / `.role()` 按 `context.locale` 本地化)