@h-ai/serv 0.1.0-alpha.33 → 0.1.0-alpha.35
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.md +1 -0
- 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 +54 -3
- package/dist/index.js +368 -51
- package/dist/index.js.map +1 -1
- package/dist/{serv-context-CwewduFi.d.ts → serv-context-CatIx8r-.d.ts} +1 -1
- package/package.json +11 -10
package/README.md
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
- 默认 feature procedures:`createIamProcedures()`、`createStorageProcedures()`、`createAiProcedures()`
|
|
11
11
|
- 内置安全响应头、健康检查、可选 OpenAPI JSON、可选 Scalar 文档页、可选内部 RPC endpoint
|
|
12
12
|
- 可选传输加密:`serv.createApp({ transport: { crypto } })` 自动挂载密钥协商与请求/响应加解密
|
|
13
|
+
- 可选语音入口:`serv.createApp({ audio: { ai } })` 在 `${apiPrefix}/ai/audio` 挂载统一语音 WebSocket,基于 `ai.audio` 提供非实时/实时的语音识别与合成(复用 `iam` 令牌校验鉴权,`serv.listen` 自动完成 WS 升级注入)
|
|
13
14
|
|
|
14
15
|
> 生命周期说明:`@h-ai/serv` 本身是无状态 HTTP App 装配器,不需要 `init()` / `close()`;请在创建 app 前初始化 `iam` / `storage` / `ai` / `crypto` 等依赖,并在 `serv.listen(..., { onClose })` 中反向释放它们。`serv.createApp()` 遇到启动期配置错误(例如 transport manager 创建失败)会 fail-fast 抛出,便于进程启动阶段暴露问题。
|
|
15
16
|
|
package/dist/features/ai.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import * as zod_v4_core from 'zod/v4/core';
|
|
|
3
3
|
import * as zod from 'zod';
|
|
4
4
|
import * as _orpc_server from '@orpc/server';
|
|
5
5
|
import { AIFunctions } from '@h-ai/ai';
|
|
6
|
-
import {
|
|
6
|
+
import { a as ServContext } from '../serv-context-CatIx8r-.js';
|
|
7
7
|
import '@h-ai/core';
|
|
8
8
|
|
|
9
9
|
/** AI 默认 procedures 依赖。 */
|
package/dist/features/iam.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ import * as zod from 'zod';
|
|
|
4
4
|
import * as _orpc_server from '@orpc/server';
|
|
5
5
|
import { IamFunctions } from '@h-ai/iam';
|
|
6
6
|
export { TokenPair } from '@h-ai/iam';
|
|
7
|
-
import {
|
|
7
|
+
import { a as ServContext } from '../serv-context-CatIx8r-.js';
|
|
8
8
|
import '@h-ai/core';
|
|
9
9
|
|
|
10
10
|
/** IAM 默认 procedures 依赖。 */
|
|
@@ -3,7 +3,7 @@ import * as zod_v4_core from 'zod/v4/core';
|
|
|
3
3
|
import * as zod from 'zod';
|
|
4
4
|
import * as _orpc_server from '@orpc/server';
|
|
5
5
|
import { StorageFunctions } from '@h-ai/storage';
|
|
6
|
-
import {
|
|
6
|
+
import { a as ServContext } from '../serv-context-CatIx8r-.js';
|
|
7
7
|
import '@h-ai/core';
|
|
8
8
|
|
|
9
9
|
/** Storage 默认 procedures 依赖。 */
|
package/dist/index.d.ts
CHANGED
|
@@ -2,14 +2,38 @@ import * as _h_ai_core from '@h-ai/core';
|
|
|
2
2
|
import { HaiResult, ValidationFormError } from '@h-ai/core';
|
|
3
3
|
import { Router, implement } from '@orpc/server';
|
|
4
4
|
import { AnyContractRouter, OpenAPI } from '@orpc/contract';
|
|
5
|
+
import { AIFunctions } from '@h-ai/ai';
|
|
6
|
+
import { S as ServSession, a as ServContext, b as ServIam, R as RefreshCookieConfig, C as CreateServContext, p as parseRequestContext, c as buildAuthContextFactory } from './serv-context-CatIx8r-.js';
|
|
7
|
+
export { d as CreateServContextInput, e as RefreshTokenPair } from './serv-context-CatIx8r-.js';
|
|
5
8
|
import { MiddlewareHandler } from 'hono';
|
|
6
|
-
import { S as ServContext, a as ServIam, R as RefreshCookieConfig, b as ServSession, C as CreateServContext, p as parseRequestContext, c as buildAuthContextFactory } from './serv-context-CwewduFi.js';
|
|
7
|
-
export { d as CreateServContextInput, e as RefreshTokenPair } from './serv-context-CwewduFi.js';
|
|
8
9
|
import { z, ZodType } from 'zod';
|
|
9
10
|
import { CryptoFunctions, TransportKeyStore } from '@h-ai/crypto';
|
|
10
11
|
import { ServerType } from '@hono/node-server';
|
|
11
12
|
import { AddressInfo } from 'node:net';
|
|
12
13
|
|
|
14
|
+
/**
|
|
15
|
+
* @h-ai/serv — 统一语音 WebSocket 接入
|
|
16
|
+
*
|
|
17
|
+
* 基于 `@h-ai/ai` 的 `ai.audio` 提供开箱即用的语音 WebSocket 端点,同时支持非实时 / 实时
|
|
18
|
+
* 的语音识别与语音合成。传输细节(WebSocket 帧、二进制/JSON 编解码)在此内部完成,
|
|
19
|
+
* 客户端只使用统一的领域协议,不接触任何厂商原生事件。
|
|
20
|
+
* @module features/serv-feature-audio
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** 语音 WebSocket 接入依赖。 */
|
|
24
|
+
interface AudioWsDeps {
|
|
25
|
+
/** AI 服务对象的语音能力(`ai.audio`)。 */
|
|
26
|
+
readonly ai: Pick<AIFunctions, 'audio'>;
|
|
27
|
+
/** 访问令牌校验(提供后连接必须携带有效令牌;未提供则不做鉴权)。 */
|
|
28
|
+
readonly verifyToken?: (token: string) => Promise<HaiResult<ServSession>>;
|
|
29
|
+
/** 单条消息字节上限(默认 1 MiB,防止内存放大)。 */
|
|
30
|
+
readonly maxMessageBytes?: number;
|
|
31
|
+
/** 单连接累计接收音频字节上限(默认 10 MiB,防止非流式缓冲无限增长)。 */
|
|
32
|
+
readonly maxBufferedBytes?: number;
|
|
33
|
+
/** 单连接最长持续时间(毫秒,默认 5 分钟,防止恶意长连接)。 */
|
|
34
|
+
readonly maxSessionMs?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
13
37
|
/**
|
|
14
38
|
* @h-ai/serv — Pipeline 共享类型
|
|
15
39
|
*
|
|
@@ -311,6 +335,33 @@ interface CreateServAppOptions<TContract extends AnyContractRouter, TProcedures
|
|
|
311
335
|
* ```
|
|
312
336
|
*/
|
|
313
337
|
readonly transport?: ServTransportConfig;
|
|
338
|
+
/**
|
|
339
|
+
* 启用统一语音 WebSocket 入口(opt-in)。提供后 serv 自动:
|
|
340
|
+
* 1. 在 `${apiPrefix}/ai/audio`(可自定义 `path`)注册语音 WebSocket 路由
|
|
341
|
+
* 2. 基于 `ai.audio` 提供非实时 / 实时的语音识别与合成
|
|
342
|
+
* 3. 复用同一套令牌校验(iam / verifyToken)对 WebSocket 连接鉴权
|
|
343
|
+
*
|
|
344
|
+
* 需配合 `serv.listen()`(Node 适配器会自动完成 WebSocket 升级注入)。
|
|
345
|
+
*
|
|
346
|
+
* @example
|
|
347
|
+
* ```ts
|
|
348
|
+
* serv.createApp({ contract, procedures, iam, audio: { ai } })
|
|
349
|
+
* ```
|
|
350
|
+
*/
|
|
351
|
+
readonly audio?: ServAudioConfig;
|
|
352
|
+
}
|
|
353
|
+
/** `serv.createApp()` 的语音 WebSocket 接入配置。 */
|
|
354
|
+
interface ServAudioConfig {
|
|
355
|
+
/** AI 服务对象(提供 `ai.audio` 领域能力)。 */
|
|
356
|
+
readonly ai: AudioWsDeps['ai'];
|
|
357
|
+
/** 语音入口路径(相对 apiPrefix,默认 `/ai/audio`)。 */
|
|
358
|
+
readonly path?: string;
|
|
359
|
+
/** 单条消息字节上限(默认 1 MiB)。 */
|
|
360
|
+
readonly maxMessageBytes?: number;
|
|
361
|
+
/** 单连接累计接收音频字节上限(默认 10 MiB)。 */
|
|
362
|
+
readonly maxBufferedBytes?: number;
|
|
363
|
+
/** 单连接最长持续时间(毫秒,默认 5 分钟)。 */
|
|
364
|
+
readonly maxSessionMs?: number;
|
|
314
365
|
}
|
|
315
366
|
/**
|
|
316
367
|
* 创建并装配 HTTP API app。
|
|
@@ -684,4 +735,4 @@ declare const serv: {
|
|
|
684
735
|
m: (key: "$schema" | "serv_validationFailed" | "serv_validationRequired" | "serv_validationInvalid" | "serv_validationInvalidType" | "serv_validationStringMin" | "serv_validationStringMax" | "serv_validationNumberMin" | "serv_validationNumberMax" | "serv_validationArrayMin" | "serv_validationArrayMax" | "serv_validationTooSmall" | "serv_validationTooBig" | "serv_validationEmail" | "serv_validationUrl" | "serv_validationUuid" | "serv_validationEnum" | "serv_errorUnauthorized" | "serv_errorForbidden" | "serv_errorInternal" | "serv_storageKeyRequired" | "serv_storageKeyTooLong" | "serv_storageKeyIllegalChars" | "serv_storageKeyDotSegments" | "serv_iamCannotDeleteCurrentUser" | "serv_transportInvalidPayload" | "serv_transportUnknownClient" | "serv_transportClientIdRequired" | "serv_transportClientKeyNotFound" | "serv_transportDecryptFailed" | "serv_transportEncryptFailed" | "serv_transportKeyExchangeFailed", options?: _h_ai_core.MessageOptions) => string;
|
|
685
736
|
};
|
|
686
737
|
|
|
687
|
-
export { type CreateServAppOptions, CreateServContext, type GenerateOpenAPISpecOptions, RefreshCookieConfig, type ServConfig, type ServConfigInput, ServConfigSchema, ServContext, type ServCorsConfig, type ServDocsHttpConfig, type ServFetchHandler, type ServGuardedProcedureWrapper, type ServHealthHttpConfig, type ServHttpApp, type ServHttpConfig, type ServHttpConfigInput, ServIam, type ServListenOptions, type ServMiddleware, type ServMiddlewareFactory, type ServMiddlewareMount, type ServNodeServer, type ServOpenAPIHttpConfig, type ServProcedureHandler, type ServProcedureOptions, type ServProcedureWrapper, type ServRpcHttpConfig, ServSession, type ServTransportConfig, type ServTransportRuntimeConfig, type ServTransportRuntimeConfigInput, type ServValidationFailureBody, serv };
|
|
738
|
+
export { type CreateServAppOptions, CreateServContext, type GenerateOpenAPISpecOptions, RefreshCookieConfig, type ServAudioConfig, type ServConfig, type ServConfigInput, ServConfigSchema, ServContext, type ServCorsConfig, type ServDocsHttpConfig, type ServFetchHandler, type ServGuardedProcedureWrapper, type ServHealthHttpConfig, type ServHttpApp, type ServHttpConfig, type ServHttpConfigInput, ServIam, type ServListenOptions, type ServMiddleware, type ServMiddlewareFactory, type ServMiddlewareMount, type ServNodeServer, type ServOpenAPIHttpConfig, type ServProcedureHandler, type ServProcedureOptions, type ServProcedureWrapper, type ServRpcHttpConfig, ServSession, type ServTransportConfig, type ServTransportRuntimeConfig, type ServTransportRuntimeConfigInput, type ServValidationFailureBody, serv };
|
package/dist/index.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { WILDCARD_PERMISSION, requirePermission } from './chunk-YQV2OMQK.js';
|
|
2
2
|
import { servM, mapHaiError, requireAuth, normalizeServLocale, buildHaiErrorBody } from './chunk-4QBF4PUG.js';
|
|
3
3
|
import { implement } from '@orpc/server';
|
|
4
|
-
import
|
|
4
|
+
import process2 from 'process';
|
|
5
5
|
import { serve } from '@hono/node-server';
|
|
6
|
-
import {
|
|
6
|
+
import { AUDIO_WS_PATH } from '@h-ai/ai';
|
|
7
|
+
import { core, ok, err, HaiCommonError } from '@h-ai/core';
|
|
8
|
+
import { createNodeWebSocket } from '@hono/node-ws';
|
|
7
9
|
import { OpenAPIHandler } from '@orpc/openapi/fetch';
|
|
8
10
|
import { RPCHandler } from '@orpc/server/fetch';
|
|
9
11
|
import { Hono } from 'hono';
|
|
12
|
+
import { Buffer } from 'buffer';
|
|
10
13
|
import { z } from 'zod';
|
|
11
14
|
import { IAM_AUTH_ROUTES } from '@h-ai/api-contract';
|
|
12
15
|
import { getCookie } from 'hono/cookie';
|
|
@@ -23,55 +26,301 @@ function toFetch(app) {
|
|
|
23
26
|
};
|
|
24
27
|
return handler;
|
|
25
28
|
}
|
|
26
|
-
var
|
|
27
|
-
var
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
29
|
+
var logger = core.logger.child({ module: "serv", scope: "audio-ws" });
|
|
30
|
+
var DEFAULT_MAX_MESSAGE_BYTES = 1048576;
|
|
31
|
+
var DEFAULT_MAX_BUFFERED_BYTES = 10 * 1024 * 1024;
|
|
32
|
+
var DEFAULT_MAX_SESSION_MS = 5 * 60 * 1e3;
|
|
33
|
+
function registerAudioWsRoute(app, path, upgradeWebSocket, deps) {
|
|
34
|
+
const maxMessageBytes = deps.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES;
|
|
35
|
+
const maxBufferedBytes = deps.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES;
|
|
36
|
+
const maxSessionMs = deps.maxSessionMs ?? DEFAULT_MAX_SESSION_MS;
|
|
37
|
+
app.get(path, upgradeWebSocket((c) => {
|
|
38
|
+
const token = c.req.query("access_token") ?? c.req.header("authorization")?.replace(/^Bearer\s+/i, "");
|
|
39
|
+
const conn = new AudioConnection(deps, token, maxMessageBytes, maxBufferedBytes);
|
|
40
|
+
let sessionTimer;
|
|
41
|
+
return {
|
|
42
|
+
onOpen(_evt, ws) {
|
|
43
|
+
sessionTimer = setTimeout(() => {
|
|
44
|
+
conn.fail(ws, "hai:ai:057", "audio session timeout");
|
|
45
|
+
}, maxSessionMs);
|
|
46
|
+
},
|
|
47
|
+
onMessage(evt, ws) {
|
|
48
|
+
conn.handleMessage(ws, evt.data).catch((error) => {
|
|
49
|
+
logger.debug("audio ws message handling failed", { error: error instanceof Error ? error.message : String(error) });
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
onClose() {
|
|
53
|
+
if (sessionTimer)
|
|
54
|
+
clearTimeout(sessionTimer);
|
|
55
|
+
conn.dispose();
|
|
56
|
+
},
|
|
57
|
+
onError() {
|
|
58
|
+
if (sessionTimer)
|
|
59
|
+
clearTimeout(sessionTimer);
|
|
60
|
+
conn.dispose();
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
var AsyncQueue = class {
|
|
66
|
+
items = [];
|
|
67
|
+
waiting = null;
|
|
68
|
+
ended = false;
|
|
69
|
+
push(item) {
|
|
70
|
+
if (this.ended)
|
|
71
|
+
return;
|
|
72
|
+
if (this.waiting) {
|
|
73
|
+
const w = this.waiting;
|
|
74
|
+
this.waiting = null;
|
|
75
|
+
w({ value: item, done: false });
|
|
76
|
+
} else {
|
|
77
|
+
this.items.push(item);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
end() {
|
|
81
|
+
if (this.ended)
|
|
82
|
+
return;
|
|
83
|
+
this.ended = true;
|
|
84
|
+
if (this.waiting) {
|
|
85
|
+
const w = this.waiting;
|
|
86
|
+
this.waiting = null;
|
|
87
|
+
w({ value: void 0, done: true });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async *iterate() {
|
|
91
|
+
while (true) {
|
|
92
|
+
if (this.items.length > 0) {
|
|
93
|
+
yield this.items.shift();
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (this.ended)
|
|
97
|
+
return;
|
|
98
|
+
const next = await new Promise((res) => {
|
|
99
|
+
this.waiting = res;
|
|
43
100
|
});
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
101
|
+
if (next.done)
|
|
102
|
+
return;
|
|
103
|
+
yield next.value;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
var AudioConnection = class {
|
|
108
|
+
constructor(deps, token, maxMessageBytes, maxBufferedBytes) {
|
|
109
|
+
this.deps = deps;
|
|
110
|
+
this.token = token;
|
|
111
|
+
this.maxMessageBytes = maxMessageBytes;
|
|
112
|
+
this.maxBufferedBytes = maxBufferedBytes;
|
|
113
|
+
}
|
|
114
|
+
started = false;
|
|
115
|
+
start;
|
|
116
|
+
audioQueue = new AsyncQueue();
|
|
117
|
+
textQueue = new AsyncQueue();
|
|
118
|
+
audioBuffer = [];
|
|
119
|
+
receivedBytes = 0;
|
|
120
|
+
controller = new AbortController();
|
|
121
|
+
closed = false;
|
|
122
|
+
async handleMessage(ws, data) {
|
|
123
|
+
if (this.closed)
|
|
124
|
+
return;
|
|
125
|
+
const bytes = toBytes(data);
|
|
126
|
+
if (bytes) {
|
|
127
|
+
if (!this.started) {
|
|
128
|
+
this.fail(ws, "hai:ai:050", "audio frame before start");
|
|
51
129
|
return;
|
|
52
|
-
shuttingDown = true;
|
|
53
|
-
try {
|
|
54
|
-
await handle.close();
|
|
55
|
-
} catch {
|
|
56
130
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
131
|
+
if (bytes.length > this.maxMessageBytes) {
|
|
132
|
+
this.fail(ws, "hai:ai:058", "audio frame too large");
|
|
133
|
+
return;
|
|
60
134
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
135
|
+
this.receivedBytes += bytes.length;
|
|
136
|
+
if (this.receivedBytes > this.maxBufferedBytes) {
|
|
137
|
+
this.fail(ws, "hai:ai:058", "buffered audio exceeds limit");
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (this.start?.stream)
|
|
141
|
+
this.audioQueue.push(bytes);
|
|
142
|
+
else
|
|
143
|
+
this.audioBuffer.push(bytes);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (typeof data !== "string")
|
|
147
|
+
return;
|
|
148
|
+
if (data.length > this.maxMessageBytes) {
|
|
149
|
+
this.fail(ws, "hai:ai:058", "message too large");
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
let message;
|
|
153
|
+
try {
|
|
154
|
+
message = JSON.parse(data);
|
|
155
|
+
} catch {
|
|
156
|
+
this.fail(ws, "hai:ai:050", "invalid json message");
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (message.type === "start") {
|
|
160
|
+
await this.onStart(ws, message);
|
|
161
|
+
} else if (!this.started) {
|
|
162
|
+
this.fail(ws, "hai:ai:050", "message before start");
|
|
163
|
+
} else if (message.type === "text") {
|
|
164
|
+
this.textQueue.push(message.text);
|
|
165
|
+
} else if (message.type === "done") {
|
|
166
|
+
this.audioQueue.end();
|
|
167
|
+
this.textQueue.end();
|
|
168
|
+
if (this.start && !this.start.stream && this.start.operation === "transcribe")
|
|
169
|
+
await this.runBufferedTranscribe(ws);
|
|
170
|
+
}
|
|
64
171
|
}
|
|
65
|
-
|
|
172
|
+
async onStart(ws, message) {
|
|
173
|
+
if (this.started) {
|
|
174
|
+
this.fail(ws, "hai:ai:050", "session already started");
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
this.started = true;
|
|
178
|
+
this.start = message;
|
|
179
|
+
if (this.deps.verifyToken) {
|
|
180
|
+
if (!this.token) {
|
|
181
|
+
this.fail(ws, "hai:ai:054", "missing access token");
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const auth = await this.deps.verifyToken(this.token);
|
|
185
|
+
if (!auth.success) {
|
|
186
|
+
this.fail(ws, "hai:ai:054", "unauthorized");
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (message.operation === "synthesize")
|
|
191
|
+
void this.runSynthesize(ws, message);
|
|
192
|
+
else if (message.operation === "transcribe" && message.stream)
|
|
193
|
+
void this.runStreamTranscribe(ws, message);
|
|
194
|
+
}
|
|
195
|
+
/** 流式识别:桥接持续音频输入,流式返回临时/最终结果与语音起止事件 */
|
|
196
|
+
async runStreamTranscribe(ws, start) {
|
|
197
|
+
try {
|
|
198
|
+
const stream = this.deps.ai.audio.transcribeStream({
|
|
199
|
+
audio: {
|
|
200
|
+
chunks: this.audioQueue.iterate(),
|
|
201
|
+
format: start.format ?? "pcm16",
|
|
202
|
+
sampleRate: start.sampleRate ?? 16e3,
|
|
203
|
+
channels: start.channels
|
|
204
|
+
},
|
|
205
|
+
language: start.language,
|
|
206
|
+
contextHints: start.contextHints,
|
|
207
|
+
model: start.model,
|
|
208
|
+
signal: this.controller.signal
|
|
209
|
+
});
|
|
210
|
+
for await (const event of stream) {
|
|
211
|
+
if (event.type === "transcript")
|
|
212
|
+
this.send(ws, { type: "transcript", text: event.text, final: event.final });
|
|
213
|
+
else if (event.type === "speech_started")
|
|
214
|
+
this.send(ws, { type: "speech_started" });
|
|
215
|
+
else if (event.type === "speech_stopped")
|
|
216
|
+
this.send(ws, { type: "speech_stopped" });
|
|
217
|
+
}
|
|
218
|
+
this.end(ws);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
this.failFromError(ws, error);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/** 非流式识别:缓冲完整音频后返回单条最终结果 */
|
|
224
|
+
async runBufferedTranscribe(ws) {
|
|
225
|
+
const start = this.start;
|
|
226
|
+
if (!start)
|
|
227
|
+
return;
|
|
228
|
+
try {
|
|
229
|
+
const result = await this.deps.ai.audio.transcribe({
|
|
230
|
+
audio: {
|
|
231
|
+
data: concat(this.audioBuffer),
|
|
232
|
+
format: start.format ?? "wav",
|
|
233
|
+
sampleRate: start.sampleRate,
|
|
234
|
+
channels: start.channels
|
|
235
|
+
},
|
|
236
|
+
language: start.language,
|
|
237
|
+
contextHints: start.contextHints,
|
|
238
|
+
model: start.model,
|
|
239
|
+
signal: this.controller.signal
|
|
240
|
+
});
|
|
241
|
+
if (!result.success) {
|
|
242
|
+
this.fail(ws, String(result.error.code), result.error.message);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
this.send(ws, { type: "transcript", text: result.data.text, final: true });
|
|
246
|
+
this.end(ws);
|
|
247
|
+
} catch (error) {
|
|
248
|
+
this.failFromError(ws, error);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/** 语音合成:桥接文本输入,流式返回音频二进制帧 */
|
|
252
|
+
async runSynthesize(ws, start) {
|
|
253
|
+
try {
|
|
254
|
+
const stream = this.deps.ai.audio.synthesizeStream({
|
|
255
|
+
text: this.textQueue.iterate(),
|
|
256
|
+
voice: start.voice,
|
|
257
|
+
instruction: start.instruction,
|
|
258
|
+
format: start.format,
|
|
259
|
+
sampleRate: start.sampleRate,
|
|
260
|
+
model: start.model,
|
|
261
|
+
signal: this.controller.signal
|
|
262
|
+
});
|
|
263
|
+
for await (const audio of stream)
|
|
264
|
+
ws.send(toArrayBuffer(audio));
|
|
265
|
+
this.end(ws);
|
|
266
|
+
} catch (error) {
|
|
267
|
+
this.failFromError(ws, error);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
fail(ws, code, message) {
|
|
271
|
+
this.send(ws, { type: "error", code, message });
|
|
272
|
+
this.dispose();
|
|
273
|
+
ws.close();
|
|
274
|
+
}
|
|
275
|
+
failFromError(ws, error) {
|
|
276
|
+
const hai = error;
|
|
277
|
+
const code = typeof hai?.code === "string" ? hai.code : "hai:ai:054";
|
|
278
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
279
|
+
this.fail(ws, code, message);
|
|
280
|
+
}
|
|
281
|
+
end(ws) {
|
|
282
|
+
this.send(ws, { type: "end" });
|
|
283
|
+
this.dispose();
|
|
284
|
+
ws.close();
|
|
285
|
+
}
|
|
286
|
+
send(ws, message) {
|
|
287
|
+
if (!this.closed)
|
|
288
|
+
ws.send(JSON.stringify(message));
|
|
289
|
+
}
|
|
290
|
+
dispose() {
|
|
291
|
+
if (this.closed)
|
|
292
|
+
return;
|
|
293
|
+
this.closed = true;
|
|
294
|
+
this.audioQueue.end();
|
|
295
|
+
this.textQueue.end();
|
|
296
|
+
this.controller.abort();
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
function toBytes(data) {
|
|
300
|
+
if (typeof data === "string")
|
|
301
|
+
return void 0;
|
|
302
|
+
if (data instanceof ArrayBuffer)
|
|
303
|
+
return new Uint8Array(data);
|
|
304
|
+
if (ArrayBuffer.isView(data))
|
|
305
|
+
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
306
|
+
if (Buffer.isBuffer(data))
|
|
307
|
+
return new Uint8Array(data);
|
|
308
|
+
return void 0;
|
|
66
309
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
310
|
+
function concat(chunks) {
|
|
311
|
+
const total = chunks.reduce((sum, c) => sum + c.length, 0);
|
|
312
|
+
const out = new Uint8Array(total);
|
|
313
|
+
let offset = 0;
|
|
314
|
+
for (const chunk of chunks) {
|
|
315
|
+
out.set(chunk, offset);
|
|
316
|
+
offset += chunk.length;
|
|
317
|
+
}
|
|
318
|
+
return out;
|
|
319
|
+
}
|
|
320
|
+
function toArrayBuffer(data) {
|
|
321
|
+
const buffer = new ArrayBuffer(data.length);
|
|
322
|
+
new Uint8Array(buffer).set(data);
|
|
323
|
+
return buffer;
|
|
75
324
|
}
|
|
76
325
|
var DEFAULT_LOCALE = "zh-CN";
|
|
77
326
|
function resolveRequestLocale(headers) {
|
|
@@ -293,7 +542,7 @@ function resolveRefreshCookieConfig(apiPrefix, config, iam) {
|
|
|
293
542
|
return {
|
|
294
543
|
cookieName: config.cookieName ?? "hai_refresh_token",
|
|
295
544
|
maxAge: config.maxAge ?? 30 * 24 * 3600,
|
|
296
|
-
isSecure: config.secure ??
|
|
545
|
+
isSecure: config.secure ?? process2.env.NODE_ENV === "production",
|
|
297
546
|
refreshCookiePath: `${apiPrefix}${IAM_AUTH_ROUTES.refresh}`,
|
|
298
547
|
onRefresh
|
|
299
548
|
};
|
|
@@ -479,7 +728,7 @@ function readScalarBrowserEntry(packageInfo) {
|
|
|
479
728
|
const browser = packageInfo.browser;
|
|
480
729
|
return typeof browser === "string" ? browser : void 0;
|
|
481
730
|
}
|
|
482
|
-
var
|
|
731
|
+
var logger2 = core.logger.child({ module: "serv", scope: "transport" });
|
|
483
732
|
var MAX_ENCRYPTED_BODY = 1048576;
|
|
484
733
|
var ENCRYPTABLE_RESPONSE_CONTENT_TYPES = ["application/json"];
|
|
485
734
|
function createTransportMiddleware(manager, keyExchangePath, excludePaths = []) {
|
|
@@ -520,7 +769,7 @@ function createTransportMiddleware(manager, keyExchangePath, excludePaths = [])
|
|
|
520
769
|
try {
|
|
521
770
|
bodyText = await response.clone().text();
|
|
522
771
|
} catch (cause) {
|
|
523
|
-
|
|
772
|
+
logger2.warn("Failed to read response body for encryption", { error: cause });
|
|
524
773
|
c.res = jsonError(servM("serv_transportEncryptFailed"), 500);
|
|
525
774
|
return;
|
|
526
775
|
}
|
|
@@ -532,7 +781,7 @@ function createTransportMiddleware(manager, keyExchangePath, excludePaths = [])
|
|
|
532
781
|
}
|
|
533
782
|
const encResult = await manager.encryptResponse(clientId, bodyText);
|
|
534
783
|
if (!encResult.success) {
|
|
535
|
-
|
|
784
|
+
logger2.warn("Failed to encrypt response", { error: encResult.error });
|
|
536
785
|
c.res = jsonError(servM("serv_transportEncryptFailed"), 500);
|
|
537
786
|
return;
|
|
538
787
|
}
|
|
@@ -564,7 +813,7 @@ async function handleKeyExchange(request, manager) {
|
|
|
564
813
|
headers: { "Content-Type": "application/json; charset=utf-8" }
|
|
565
814
|
});
|
|
566
815
|
} catch (cause) {
|
|
567
|
-
|
|
816
|
+
logger2.error("Key exchange failed", { error: cause });
|
|
568
817
|
return jsonError(servM("serv_transportKeyExchangeFailed"), 500);
|
|
569
818
|
}
|
|
570
819
|
}
|
|
@@ -582,7 +831,7 @@ async function decryptRequestInPlace(c, manager) {
|
|
|
582
831
|
return jsonError(servM("serv_transportInvalidPayload"), 400);
|
|
583
832
|
const decResult = manager.decryptRequest(payload);
|
|
584
833
|
if (!decResult.success) {
|
|
585
|
-
|
|
834
|
+
logger2.warn("Failed to decrypt request", { error: decResult.error });
|
|
586
835
|
return jsonError(servM("serv_transportDecryptFailed"), 400);
|
|
587
836
|
}
|
|
588
837
|
const original = c.req.raw;
|
|
@@ -639,6 +888,7 @@ function isEncryptedPayloadShape(value) {
|
|
|
639
888
|
|
|
640
889
|
// src/serv-app.ts
|
|
641
890
|
var API_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
|
|
891
|
+
var audioWsInjectors = /* @__PURE__ */ new WeakMap();
|
|
642
892
|
function createApp(options) {
|
|
643
893
|
const http = resolveServHttpConfig(options.http);
|
|
644
894
|
const app = new Hono();
|
|
@@ -672,6 +922,18 @@ function createApp(options) {
|
|
|
672
922
|
mountHealthRoutes(app, http.health);
|
|
673
923
|
if (options.refreshCookie)
|
|
674
924
|
mountRefreshCookieRoutes(app, http.apiPrefix, options.refreshCookie, options.iam);
|
|
925
|
+
if (options.audio) {
|
|
926
|
+
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
|
|
927
|
+
const audioPath = `${http.apiPrefix}${options.audio.path ?? AUDIO_WS_PATH}`;
|
|
928
|
+
registerAudioWsRoute(app, audioPath, upgradeWebSocket, {
|
|
929
|
+
ai: options.audio.ai,
|
|
930
|
+
verifyToken,
|
|
931
|
+
maxMessageBytes: options.audio.maxMessageBytes,
|
|
932
|
+
maxBufferedBytes: options.audio.maxBufferedBytes,
|
|
933
|
+
maxSessionMs: options.audio.maxSessionMs
|
|
934
|
+
});
|
|
935
|
+
audioWsInjectors.set(app, injectWebSocket);
|
|
936
|
+
}
|
|
675
937
|
mountOpenAPIRoutes(app, options.procedures, http.apiPrefix, createContext);
|
|
676
938
|
if (http.rpc !== false)
|
|
677
939
|
mountRPCRoutes(app, options.procedures, http.rpc, createContext);
|
|
@@ -779,6 +1041,61 @@ function isOrpcValidationErrorPayload(value) {
|
|
|
779
1041
|
return Array.isArray(issues);
|
|
780
1042
|
}
|
|
781
1043
|
|
|
1044
|
+
// src/adapters/serv-adapter-node.ts
|
|
1045
|
+
var DEFAULT_SERV_HOST = "127.0.0.1";
|
|
1046
|
+
var DEFAULT_SERV_PORT = 3e3;
|
|
1047
|
+
function listen(app, options = {}) {
|
|
1048
|
+
const host = options.host ?? process2.env.HOST ?? DEFAULT_SERV_HOST;
|
|
1049
|
+
const port = options.port ?? (process2.env.PORT ? Number(process2.env.PORT) : DEFAULT_SERV_PORT);
|
|
1050
|
+
const server = serve(
|
|
1051
|
+
{ fetch: app.fetch, hostname: host, port },
|
|
1052
|
+
options.onListening
|
|
1053
|
+
);
|
|
1054
|
+
const injectWebSocket = audioWsInjectors.get(app);
|
|
1055
|
+
if (injectWebSocket)
|
|
1056
|
+
injectWebSocket(server);
|
|
1057
|
+
const handle = {
|
|
1058
|
+
server,
|
|
1059
|
+
address: { host, port },
|
|
1060
|
+
close: () => new Promise((resolve, reject) => {
|
|
1061
|
+
server.close((error) => {
|
|
1062
|
+
if (error)
|
|
1063
|
+
reject(error);
|
|
1064
|
+
else resolve();
|
|
1065
|
+
});
|
|
1066
|
+
})
|
|
1067
|
+
};
|
|
1068
|
+
if (options.onClose) {
|
|
1069
|
+
const onClose = options.onClose;
|
|
1070
|
+
let shuttingDown = false;
|
|
1071
|
+
const shutdown = async () => {
|
|
1072
|
+
if (shuttingDown)
|
|
1073
|
+
return;
|
|
1074
|
+
shuttingDown = true;
|
|
1075
|
+
try {
|
|
1076
|
+
await handle.close();
|
|
1077
|
+
} catch {
|
|
1078
|
+
}
|
|
1079
|
+
try {
|
|
1080
|
+
await onClose();
|
|
1081
|
+
} catch {
|
|
1082
|
+
}
|
|
1083
|
+
};
|
|
1084
|
+
process2.once("SIGINT", () => void shutdown());
|
|
1085
|
+
process2.once("SIGTERM", () => void shutdown());
|
|
1086
|
+
}
|
|
1087
|
+
return handle;
|
|
1088
|
+
}
|
|
1089
|
+
var WILDCARD_ROLE = "*";
|
|
1090
|
+
function requireRole(role, handler) {
|
|
1091
|
+
return requireAuth(async (options) => {
|
|
1092
|
+
const roles = options.context.session?.roles ?? [];
|
|
1093
|
+
if (!roles.includes(role) && !roles.includes(WILDCARD_ROLE))
|
|
1094
|
+
return err(HaiCommonError.FORBIDDEN, servM("serv_errorForbidden", { locale: options.context.locale }));
|
|
1095
|
+
return handler(options);
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
|
|
782
1099
|
// src/serv-cors.ts
|
|
783
1100
|
var DEFAULT_CORS_METHODS = ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE", "OPTIONS"];
|
|
784
1101
|
var DEFAULT_CORS_ALLOWED_HEADERS = ["Authorization", "Content-Type", "X-Requested-With"];
|