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