@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 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
 
@@ -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 { S as ServContext } from '../serv-context-CwewduFi.js';
6
+ import { a as ServContext } from '../serv-context-CatIx8r-.js';
7
7
  import '@h-ai/core';
8
8
 
9
9
  /** AI 默认 procedures 依赖。 */
@@ -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 { S as ServContext } from '../serv-context-CwewduFi.js';
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 { S as ServContext } from '../serv-context-CwewduFi.js';
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 process from 'process';
4
+ import process2 from 'process';
5
5
  import { serve } from '@hono/node-server';
6
- import { core, err, HaiCommonError, ok } from '@h-ai/core';
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 DEFAULT_SERV_HOST = "127.0.0.1";
27
- var DEFAULT_SERV_PORT = 3e3;
28
- function listen(app, options = {}) {
29
- const host = options.host ?? process.env.HOST ?? DEFAULT_SERV_HOST;
30
- const port = options.port ?? (process.env.PORT ? Number(process.env.PORT) : DEFAULT_SERV_PORT);
31
- const server = serve(
32
- { fetch: app.fetch, hostname: host, port },
33
- options.onListening
34
- );
35
- const handle = {
36
- server,
37
- address: { host, port },
38
- close: () => new Promise((resolve, reject) => {
39
- server.close((error) => {
40
- if (error)
41
- reject(error);
42
- else resolve();
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
- if (options.onClose) {
47
- const onClose = options.onClose;
48
- let shuttingDown = false;
49
- const shutdown = async () => {
50
- if (shuttingDown)
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
- try {
58
- await onClose();
59
- } catch {
131
+ if (bytes.length > this.maxMessageBytes) {
132
+ this.fail(ws, "hai:ai:058", "audio frame too large");
133
+ return;
60
134
  }
61
- };
62
- process.once("SIGINT", () => void shutdown());
63
- process.once("SIGTERM", () => void shutdown());
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
- return handle;
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
- var WILDCARD_ROLE = "*";
68
- function requireRole(role, handler) {
69
- return requireAuth(async (options) => {
70
- const roles = options.context.session?.roles ?? [];
71
- if (!roles.includes(role) && !roles.includes(WILDCARD_ROLE))
72
- return err(HaiCommonError.FORBIDDEN, servM("serv_errorForbidden", { locale: options.context.locale }));
73
- return handler(options);
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 ?? process.env.NODE_ENV === "production",
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 logger = core.logger.child({ module: "serv", scope: "transport" });
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
- logger.warn("Failed to read response body for encryption", { error: cause });
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
- logger.warn("Failed to encrypt response", { error: encResult.error });
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
- logger.error("Key exchange failed", { error: cause });
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
- logger.warn("Failed to decrypt request", { error: decResult.error });
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"];