@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.en.md +655 -0
- package/README.ja.md +655 -0
- package/README.md +9 -1
- package/dist/features/ai.d.ts +1 -1
- package/dist/features/iam.d.ts +1 -1
- package/dist/features/storage.d.ts +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js.map +1 -1
- package/dist/{serv-context-jc3rZxbj.d.ts → serv-context-C8JUUm6I.d.ts} +3 -3
- package/package.json +10 -10
package/README.en.md
ADDED
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
# @h-ai/serv
|
|
2
|
+
|
|
3
|
+
[简体中文](./README.md) | [English](./README.en.md) | [日本語](./README.ja.md)
|
|
4
|
+
|
|
5
|
+
[Framework home](../../README.en.md) · Related modules: [@h-ai/ai](../ai/README.en.md) · [@h-ai/api-contract](../api-contract/README.en.md) · [@h-ai/core](../core/README.en.md) · [@h-ai/crypto](../crypto/README.en.md) · [@h-ai/iam](../iam/README.en.md) · [@h-ai/storage](../storage/README.en.md)
|
|
6
|
+
|
|
7
|
+
> hai-framework's API service runtime: a stable public HTTP App abstraction that mounts `@h-ai/api-contract` contracts as cross-platform HTTP APIs. Hono is an internal implementation detail.
|
|
8
|
+
|
|
9
|
+
## Capabilities
|
|
10
|
+
|
|
11
|
+
- Start with `serv.createApp(...)` → `serv.listen(app, { port })`; applications need not import Hono.
|
|
12
|
+
- Flat API: `serv.createApp / serv.implement / serv.listen / serv.toFetch / serv.generateSpec / ...`.
|
|
13
|
+
- Procedure chains: `implement().context().route().auth/permission/role().handle().build()`.
|
|
14
|
+
- Custom pipelines: mount HTTP middleware with `createApp({ middlewares })`; inject request fields through context factories.
|
|
15
|
+
- Read-only storage assets: `serv.storageAssets(...)` handles safe keys, MIME allowlists, GET/HEAD, ETags, and cache headers.
|
|
16
|
+
- Default feature procedures: `createIamProcedures()`, `createStorageProcedures()`, `createAiProcedures()`.
|
|
17
|
+
- Built-in security headers and health checks; optional OpenAPI JSON, Scalar docs, and internal RPC endpoints.
|
|
18
|
+
- Optional transport encryption: `serv.createApp({ transport: { crypto } })` mounts key exchange and request/response encryption automatically.
|
|
19
|
+
- Optional audio: `serv.createApp({ audio: { ai, verifyTicket, authorize } })` mounts a unified audio WebSocket. Connections authenticate immediately with one-time tickets (`{ session, grant? }`, separate pre-auth timeout). Every frame uses runtime Zod validation and a strict state machine, with input/queue/send-buffer backpressure and cascading cancellation. Authorization verifies operations, models, voices, quotas, and concurrency on the server.
|
|
20
|
+
|
|
21
|
+
> Lifecycle: `@h-ai/serv` is a stateless HTTP App assembler and needs no `init()` / `close()`. Initialize `iam` / `storage` / `ai` / `crypto` before app creation, then release them in reverse order through `serv.listen(..., { onClose })`. `serv.createApp()` throws immediately on startup configuration errors such as transport manager creation failure.
|
|
22
|
+
|
|
23
|
+
## Quick start
|
|
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 configuration
|
|
66
|
+
|
|
67
|
+
Host/port come from `server` in `config/_serv.yml`: `HOST` / `PORT` environment variables > configuration > `127.0.0.1:3000`. The default `127.0.0.1` is local-only; use `0.0.0.0` to expose a container:
|
|
68
|
+
|
|
69
|
+
```yaml
|
|
70
|
+
# config/_serv.yml
|
|
71
|
+
server:
|
|
72
|
+
host: 127.0.0.1
|
|
73
|
+
port: 3000
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Pass the resolved `server` to `serv.listen`; explicit `host` / `port` arguments can also override it.
|
|
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: tickets and server authorization
|
|
86
|
+
|
|
87
|
+
Audio connections require short-lived, one-time tickets. Issue them through an authenticated HTTP endpoint (recommended: `iam.ticket.issue`) and consume atomically in `verifyTicket` (recommended: `iam.ticket.consume`). Authentication runs at connection time with a separate timeout; no business frames are processed beforehand. The framework stores the returned `ServSession` and optional `grant`, then calls `authorize` to approve paid parameters:
|
|
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)` must check purpose/expiration and allow one successful use, returning `HaiResult<AudioTicketVerification>` (`{ session, grant? }`). `grant` carries ticket-bound operation/model/session data.
|
|
104
|
+
- `authorize(session, request, grant)` checks IAM permissions, model/voice allowlists, Persona, quotas, and concurrency slots. Cross-check `start` against `grant`; only returned `AuthorizedAudioRequest` reaches `ai.audio`.
|
|
105
|
+
- Without `authorize`, client `model`, `voice`, and `instruction` are ignored. Only server defaults (or `grant.model`) are used; `start` mismatching `grant.operation` is rejected.
|
|
106
|
+
- Each frame is validated with Zod and a strict state machine: transcription accepts audio, synthesis accepts text, `start`/`done` occur once, and `segmentId` is unique per session. Input, pending-message, and send buffers are bounded; limits or cancellation close with domain errors and abort upstream work.
|
|
107
|
+
- Tunables: `preAuthTimeoutMs` (5000), `maxPendingMessages` (256), `maxSendBufferBytes` (8 MiB), and `maxMessageBytes` / `maxBufferedBytes` / `maxTextBytes` / `maxSessionMs`.
|
|
108
|
+
- `onSessionEnd` runs at most once after success, failure, or disconnection to release concurrency slots.
|
|
109
|
+
- Browser `createAIClient({ audio: { url, getTicket } })` puts only one-time `ticket` in the WebSocket URL. `getTicket(request)` receives the operation summary (`operation` / `model`) for a tightly bound ticket. Never pass ordinary IAM access tokens.
|
|
110
|
+
|
|
111
|
+
### Transport encryption (shared with `@h-ai/crypto`)
|
|
112
|
+
|
|
113
|
+
serv does not expose its own encryption factory; the server manager comes from `crypto.transport`. Applications only pass the top-level `crypto` handle:
|
|
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
|
+
Clients use `@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
|
+
When using `@h-ai/core` and `ServConfigSchema` with `config/_serv.yml`, transport can be configured together:
|
|
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
|
+
> **Multi-node deployments**: transport session keys are stored **in the server process** by default. Replicas require one of these:
|
|
193
|
+
>
|
|
194
|
+
> - Enable **sticky sessions** in the load balancer so each client reaches the same node.
|
|
195
|
+
> - Perform a separate initial key exchange for each client on each node.
|
|
196
|
+
> - Inject a shared `keyStore`, such as `createRedisTransportKeyStore()` / `createReldbTransportKeyStore()`.
|
|
197
|
+
>
|
|
198
|
+
> `keyStore` is a runtime object dependency and must not go in `config/_serv.yml`; configuration contains only static paths, allowlists, and capacities.
|
|
199
|
+
|
|
200
|
+
> **Security**: with `transport` enabled, business requests need valid `X-Client-Id` except `excludePaths` and key exchange. Missing/unknown clientId returns 400. Unencryptable bodies, non-JSON responses, or encryption size limits return errors rather than forwarding plaintext business data.
|
|
201
|
+
|
|
202
|
+
### Token authentication (`iam` / `refreshCookie`)
|
|
203
|
+
|
|
204
|
+
Pass the top-level `iam` handle to `serv.createApp` to derive:
|
|
205
|
+
|
|
206
|
+
- **Access token verification**: `iam.session.verifyToken` fills `context.session` on every request without caching.
|
|
207
|
+
- **Refresh rotation**: with `refreshCookie`, `iam.session.refresh` becomes the httpOnly-cookie refresh callback.
|
|
208
|
+
|
|
209
|
+
**Context factory precedence**: explicit `createContext` > `verifyToken` > `iam.session.verifyToken` > metadata-only default.
|
|
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
|
+
### Custom procedures (authentication + permissions)
|
|
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
|
+
### Advanced pipelines (HTTP middleware + context + route guards)
|
|
259
|
+
|
|
260
|
+
`@h-ai/serv` pipelines have three layers and extension points:
|
|
261
|
+
|
|
262
|
+
1. **HTTP middleware**: inject through `serv.createApp({ middlewares })`.
|
|
263
|
+
2. **Context**: customize through `verifyToken` / `createContext` / `serv.buildAuthContextFactory()`.
|
|
264
|
+
3. **Route guards**: declare authentication/authorization with `.auth()` / `.permission()` / `.role()`.
|
|
265
|
+
|
|
266
|
+
#### 1) Custom HTTP middleware
|
|
267
|
+
|
|
268
|
+
Use `middlewares` for **HTTP cross-cutting logic** such as logging, tracing, rate limits, CORS, and tenant headers, or WebSocket/file/binary endpoints unsuited to JSON/oRPC contracts. Reuse `serv.cors(...)` for common CORS needs:
|
|
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
|
+
Execution order is fixed:
|
|
317
|
+
|
|
318
|
+
1. Built-in `securityHeaders`.
|
|
319
|
+
2. Your `middlewares`.
|
|
320
|
+
3. Built-in `transport`, if enabled.
|
|
321
|
+
4. health / refresh-cookie / OpenAPI / RPC / docs / oRPC routes
|
|
322
|
+
|
|
323
|
+
Notes:
|
|
324
|
+
|
|
325
|
+
- `middlewares` register in array order.
|
|
326
|
+
- Omitted `path` defaults to `'*'`.
|
|
327
|
+
- Middleware runs before transport, so CORS preflight or binary endpoints can short-circuit. Responses retain security headers but bypass transport.
|
|
328
|
+
- Prefer `serv.storageAssets(...)` for public read-only files. Applications must supply key/MIME allowlists, caching, and CORS; no business concepts such as avatars are built in.
|
|
329
|
+
- Private resources, Range downloads, and dynamic authorization need custom middleware/procedures, not the public asset helper.
|
|
330
|
+
- To read decrypted business bodies, extend context/procedure instead.
|
|
331
|
+
- Expose custom response headers such as `X-Encrypted` explicitly through `serv.cors({ exposedHeaders: [...] })` for browsers.
|
|
332
|
+
- This layer works with HTTP middleware context and returns **HTTP Response**, not `HaiResult`.
|
|
333
|
+
|
|
334
|
+
#### 2) Custom context pipelines
|
|
335
|
+
|
|
336
|
+
To reuse `Bearer token -> session` and add tenant, workspace, or source-system fields,
|
|
337
|
+
prefer `serv.buildAuthContextFactory()` to assemble a custom `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
|
+
Choose the appropriate extension:
|
|
356
|
+
|
|
357
|
+
- Replace token verification only: use `verifyToken`.
|
|
358
|
+
- Add fields to default authentication context: use `buildAuthContextFactory(...) + createContext`.
|
|
359
|
+
- Replace context construction entirely: use `createContext` directly.
|
|
360
|
+
|
|
361
|
+
#### 3) Route guard
|
|
362
|
+
|
|
363
|
+
Public procedures use `.route(path, handler)`. For guarded routes, use
|
|
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
|
+
Constraints:
|
|
382
|
+
|
|
383
|
+
- `.permission()` and `.role()` require authentication automatically. Failures return HaiResult with `error.httpStatus` 401/403; ordinary procedure responses remain HTTP 200 by default.
|
|
384
|
+
- Multiple permissions/roles on one route must all pass. `serv.WILDCARD_PERMISSION` / `serv.WILDCARD_ROLE` allow their respective categories.
|
|
385
|
+
- Unhandled handler exceptions become INTERNAL_ERROR HaiResult values; expected business failures should return `err(...)` directly.
|
|
386
|
+
- Paths, input, and output come from the contract. Duplicate/unknown paths are rejected; `.build()` requires complete contract implementation.
|
|
387
|
+
|
|
388
|
+
Public API boundaries:
|
|
389
|
+
|
|
390
|
+
- `src/pipelines/*` holds internal defaults and is not a public subpath API.
|
|
391
|
+
- The stable application-facing entry remains `@h-ai/serv`.
|
|
392
|
+
- Internal helpers such as `securityHeaders` / `requireInternalRPC` / `buildHaiErrorBody` are not documented as separate subpath exports.
|
|
393
|
+
|
|
394
|
+
### httpOnly cookie authentication (recommended for production)
|
|
395
|
+
|
|
396
|
+
httpOnly cookie mode stores refresh tokens in server-managed cookies unreadable to browser JavaScript, reducing XSS exposure.
|
|
397
|
+
|
|
398
|
+
Distinguish the two token responsibilities:
|
|
399
|
+
|
|
400
|
+
- `accessToken`: a short-lived credential kept in client memory and sent as `Authorization: Bearer <token>`.
|
|
401
|
+
`serv.parseRequestContext()` / `extractBearerToken()` read it; `buildAuthContextFactory()` verifies it.
|
|
402
|
+
- `refreshToken`: a long-lived credential. With `refreshCookie`, it is hidden from frontend JavaScript and stored only in an httpOnly cookie.
|
|
403
|
+
`extractBearerToken()` does **not** read it. Only `/auth/refresh` reads it on the server to issue a new access token.
|
|
404
|
+
Trusted native clients can use JSON bodies through `nativeTokenTransport`, with both client identity and Origin verification required.
|
|
405
|
+
|
|
406
|
+
**Workflow:**
|
|
407
|
+
|
|
408
|
+
1. Browser login: server sets `Set-Cookie: hai_refresh_token=...;HttpOnly` and removes `refreshToken` from JSON.
|
|
409
|
+
2. Access token stays in client memory without persistence.
|
|
410
|
+
3. On access token expiration, the browser includes the cookie automatically when calling `/auth/refresh`.
|
|
411
|
+
4. Server reads the cookie, calls `onRefresh`, returns a new access token, and updates the cookie without exposing refresh token in the body.
|
|
412
|
+
|
|
413
|
+
For native clients matching `nativeTokenTransport.isRequest`, login/registration/refresh responses retain the rotated
|
|
414
|
+
`refreshToken`. Refresh reads `{ refreshToken }` from the body, and the entire flow avoids browser cookies.
|
|
415
|
+
|
|
416
|
+
**Server configuration:**
|
|
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
|
+
**Client configuration (with `@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 attributes**: `HttpOnly; SameSite=Strict; Secure` in production; `Path` is limited to `{apiPrefix}/auth/refresh`.
|
|
452
|
+
|
|
453
|
+
## API overview
|
|
454
|
+
|
|
455
|
+
- `serv.createApp(options)`: create the HTTP App with custom middleware, health checks, OpenAPI, optional docs, and RPC.
|
|
456
|
+
- `serv.parseRequestContext({ request })`: extract Bearer token + requestId without populating session.
|
|
457
|
+
- `serv.listen(app, options)`: start Node.js HTTP service; return `{ server, address, close }`.
|
|
458
|
+
- `serv.toFetch(app)`: wrap as a standard `fetch(Request)` handler.
|
|
459
|
+
- `serv.generateSpec(contract, options)`: generate an OpenAPI 3.1 spec from the contract.
|
|
460
|
+
- `serv.storageAssets(config)`: read-only storage middleware with key/MIME allowlists, GET/HEAD, and ETags.
|
|
461
|
+
- `serv.implement(contract)`: create a chainable contract implementer.
|
|
462
|
+
- `.context<ServContext>()`: declare the procedure request context.
|
|
463
|
+
- `.route(path, handler)`: register a public procedure.
|
|
464
|
+
- `.route(path).auth/permission/role().handle(handler)`: register an authenticated/authorized procedure.
|
|
465
|
+
- `.build()`: build the router after implementing every contract procedure.
|
|
466
|
+
- `serv.validateInputOrFail(zodSchema, input, locale)`: secondary Zod validation in procedures; failure returns localized `HaiResult` + `ValidationFormError[]`.
|
|
467
|
+
- `serv.resolveRequestLocale(headers)`: parse and normalize locale from `x-hai-locale` / `Accept-Language`.
|
|
468
|
+
- `serv.m(key, options)`: read module messages with per-call `options.locale` localization.
|
|
469
|
+
|
|
470
|
+
Additional root types / named exports:
|
|
471
|
+
|
|
472
|
+
- `ServConfigSchema`: validate `config/_serv.yml` with `core.config.validate('serv', ServConfigSchema)`.
|
|
473
|
+
- HTTP App abstraction: `serv.createApp()` returns public `fetch/request` capabilities; internal Hono usage does not affect application code.
|
|
474
|
+
- `ServMiddlewareMount`: entries for `createApp({ middlewares })` (`{ path?, middleware }`).
|
|
475
|
+
- `ServMiddleware` / `ServProcedureHandler`: shared types for custom pipelines.
|
|
476
|
+
- `NativeRefreshTokenTransportConfig` / `ServStorageAssetsConfig`: native token transport and read-only asset configuration.
|
|
477
|
+
|
|
478
|
+
> Transport encryption is a `createApp` option delegated internally to `crypto.transport`, not a flat `serv.xxx` API.
|
|
479
|
+
|
|
480
|
+
## Configuration
|
|
481
|
+
|
|
482
|
+
`@h-ai/serv` does not scan YAML itself. Load `config/_serv.yml` through `@h-ai/core` and pass the result to `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
|
+
Example `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` environment variables > configuration > `127.0.0.1:3000`. Applications pass `servConfig.server` to `serv.listen`.
|
|
552
|
+
|
|
553
|
+
`ServHttpConfigInput`:
|
|
554
|
+
|
|
555
|
+
- `apiPrefix`: defaults to `/api/v1`.
|
|
556
|
+
- `health`: defaults to `{ path: '/health', readyPath: '/ready' }`.
|
|
557
|
+
- `openapi`: defaults to `false`; enable with `{ path: '/openapi.json' }`.
|
|
558
|
+
- `docs`: defaults to `false`; enable with `{ path: '/docs' }` (requires `openapi`). It mounts `/_hai/scalar.js` from the `@scalar/api-reference` browser bundle for local Scalar UI without an external CDN.
|
|
559
|
+
- `rpc`: defaults to `false`; enable with `{ prefix: '/rpc', access: 'loopback' | 'private-network' | 'gateway-only', gatewayHeader?, gatewaySecret? }`.
|
|
560
|
+
- `cors`: configured origins, credentials, allowedHeaders, and exposedHeaders; mount through `serv.cors(...)`.
|
|
561
|
+
- `transport`: top-level `config/_serv.yml` option passed as `servConfig.transport` to `createApp({ transport })`; default key exchange subpath is `/_hai/key-exchange`.
|
|
562
|
+
|
|
563
|
+
In production, use `serv.createRuntimeSecurityPolicy(...)` to constrain CORS, cookies, and documentation exposure:
|
|
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
|
+
In production, empty or `*` `_serv.yml` `cors.origin` fails startup. Web and Capacitor/Tauri origins use exact normalized full-Origin matching to prevent suffix spoofing. Production also enforces Secure refresh cookies and disables OpenAPI/docs; configuration controls nonproduction endpoints.
|
|
593
|
+
|
|
594
|
+
## Error handling
|
|
595
|
+
|
|
596
|
+
Ordinary procedures return authentication, authorization, business failures and caught handler exceptions as HaiResult over HTTP 200 by default. `error.httpStatus` is domain-error metadata and does not change the response status. Clients must check `success` and `error.code`: an unauthenticated failure has `error.httpStatus=401`, while database query failures or INTERNAL_ERROR have 500.
|
|
597
|
+
|
|
598
|
+
oRPC contract input validation fails before procedures with HTTP 400 and a localized body containing `error.httpStatus=400` and field errors. Procedure-level `serv.validateInputOrFail` failures remain HTTP 200. HTTP middleware, refresh-cookie, OpenAPI/RPC access checks and transport protocol failures follow their own HTTP response contracts. api-client refreshes only after an actual HTTP 401; `error.httpStatus=401` inside an HTTP 200 envelope does not trigger refresh.
|
|
599
|
+
|
|
600
|
+
- All default procedures return `HaiResult<T>`; clients check `result.success`.
|
|
601
|
+
- Authentication failure → `HaiCommonError.UNAUTHORIZED`, localized by `.auth()` using `context.locale`.
|
|
602
|
+
- Authorization failure → `HaiCommonError.FORBIDDEN`, localized by `.permission()` / `.role()` using `context.locale`.
|
|
603
|
+
- Uncaught exception → `HaiCommonError.INTERNAL_ERROR`, handled and localized by the route implementer using `context.locale`.
|
|
604
|
+
|
|
605
|
+
Handle normal business requests at three layers:
|
|
606
|
+
|
|
607
|
+
1. **oRPC contract input validation**: runs before procedures. `serv.createApp()` intercepts default English Zod errors and rewrites them as localized `errors[]`.
|
|
608
|
+
2. **Procedure-level validation**: use `serv.validateInputOrFail(zodSchema, input, context.locale)` for DB-read objects, cross-field constraints, and other Zod-friendly rules. Simple business rules can return `err(...)`; no extra schema is needed just for uniformity.
|
|
609
|
+
3. **Business/domain errors**: do not throw expected failures; return `err(...)` and localize **where the message is created**, using the relevant module getter such as `serv.m(..., { locale: context.locale })` or `widgetM(..., { locale: context.locale })`.
|
|
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
|
+
> When downstream modules (such as `iam.user.xxx()`) return `HaiResult` directly, serv forwards `error.message` unchanged. `HaiError` holds only the final `message`, not `messageKey/params`, so **serv cannot generically retranslate arbitrary downstream errors**. Request-level localization must receive locale at the module that creates the error message.
|
|
644
|
+
|
|
645
|
+
## Tests
|
|
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
|