@h-ai/serv 0.1.0-alpha.34 → 0.1.0-alpha.36

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/dist/index.js CHANGED
@@ -1,12 +1,14 @@
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 { core, ok, err, HaiCommonError } from '@h-ai/core';
7
+ import { createNodeWebSocket } from '@hono/node-ws';
7
8
  import { OpenAPIHandler } from '@orpc/openapi/fetch';
8
9
  import { RPCHandler } from '@orpc/server/fetch';
9
10
  import { Hono } from 'hono';
11
+ import { Buffer } from 'buffer';
10
12
  import { z } from 'zod';
11
13
  import { IAM_AUTH_ROUTES } from '@h-ai/api-contract';
12
14
  import { getCookie } from 'hono/cookie';
@@ -23,55 +25,343 @@ function toFetch(app) {
23
25
  };
24
26
  return handler;
25
27
  }
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();
28
+ var logger = core.logger.child({ module: "serv", scope: "audio-ws" });
29
+ var DEFAULT_MAX_MESSAGE_BYTES = 1048576;
30
+ var DEFAULT_MAX_BUFFERED_BYTES = 10 * 1024 * 1024;
31
+ var DEFAULT_MAX_TEXT_BYTES = 1 * 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 maxTextBytes = deps.maxTextBytes ?? DEFAULT_MAX_TEXT_BYTES;
37
+ const maxSessionMs = deps.maxSessionMs ?? DEFAULT_MAX_SESSION_MS;
38
+ app.get(path, upgradeWebSocket((c) => {
39
+ const ticket = c.req.query("ticket");
40
+ const conn = new AudioConnection(deps, ticket, maxMessageBytes, maxBufferedBytes, maxTextBytes);
41
+ let sessionTimer;
42
+ return {
43
+ onOpen(_evt, ws) {
44
+ sessionTimer = setTimeout(() => {
45
+ conn.fail(ws, "hai:ai:057", "audio session timeout");
46
+ }, maxSessionMs);
47
+ },
48
+ onMessage(evt, ws) {
49
+ conn.enqueue(ws, evt.data);
50
+ },
51
+ onClose() {
52
+ if (sessionTimer)
53
+ clearTimeout(sessionTimer);
54
+ conn.dispose();
55
+ },
56
+ onError() {
57
+ if (sessionTimer)
58
+ clearTimeout(sessionTimer);
59
+ conn.dispose();
60
+ }
61
+ };
62
+ }));
63
+ }
64
+ var AsyncQueue = class {
65
+ items = [];
66
+ waiting = null;
67
+ ended = false;
68
+ push(item) {
69
+ if (this.ended)
70
+ return;
71
+ if (this.waiting) {
72
+ const w = this.waiting;
73
+ this.waiting = null;
74
+ w({ value: item, done: false });
75
+ } else {
76
+ this.items.push(item);
77
+ }
78
+ }
79
+ end() {
80
+ if (this.ended)
81
+ return;
82
+ this.ended = true;
83
+ if (this.waiting) {
84
+ const w = this.waiting;
85
+ this.waiting = null;
86
+ w({ value: void 0, done: true });
87
+ }
88
+ }
89
+ async *iterate() {
90
+ while (true) {
91
+ if (this.items.length > 0) {
92
+ yield this.items.shift();
93
+ continue;
94
+ }
95
+ if (this.ended)
96
+ return;
97
+ const next = await new Promise((res) => {
98
+ this.waiting = res;
43
99
  });
44
- })
45
- };
46
- if (options.onClose) {
47
- const onClose = options.onClose;
48
- let shuttingDown = false;
49
- const shutdown = async () => {
50
- if (shuttingDown)
100
+ if (next.done)
101
+ return;
102
+ yield next.value;
103
+ }
104
+ }
105
+ };
106
+ var AudioConnection = class {
107
+ constructor(deps, ticket, maxMessageBytes, maxBufferedBytes, maxTextBytes) {
108
+ this.deps = deps;
109
+ this.ticket = ticket;
110
+ this.maxMessageBytes = maxMessageBytes;
111
+ this.maxBufferedBytes = maxBufferedBytes;
112
+ this.maxTextBytes = maxTextBytes;
113
+ }
114
+ started = false;
115
+ start;
116
+ session;
117
+ authorizedRequest;
118
+ audioQueue = new AsyncQueue();
119
+ textQueue = new AsyncQueue();
120
+ audioBuffer = [];
121
+ receivedBytes = 0;
122
+ receivedTextBytes = 0;
123
+ controller = new AbortController();
124
+ closed = false;
125
+ /** 消息串行处理链:确保同一连接的消息按到达顺序处理,鉴权完成前不处理业务帧。 */
126
+ queue = Promise.resolve();
127
+ /** 将消息追加到串行处理链,保证按序处理(start 的鉴权完成前不会处理后续业务帧)。 */
128
+ enqueue(ws, data) {
129
+ this.queue = this.queue.then(() => this.handleMessage(ws, data)).catch((error) => {
130
+ logger.debug("audio ws message handling failed", { error: error instanceof Error ? error.message : String(error) });
131
+ if (!this.closed)
132
+ this.failFromError(ws, error);
133
+ });
134
+ }
135
+ async handleMessage(ws, data) {
136
+ if (this.closed)
137
+ return;
138
+ const bytes = toBytes(data);
139
+ if (bytes) {
140
+ if (!this.started) {
141
+ this.fail(ws, "hai:ai:050", "audio frame before start");
51
142
  return;
52
- shuttingDown = true;
53
- try {
54
- await handle.close();
55
- } catch {
56
143
  }
57
- try {
58
- await onClose();
59
- } catch {
144
+ if (bytes.length > this.maxMessageBytes) {
145
+ this.fail(ws, "hai:ai:058", "audio frame too large");
146
+ return;
147
+ }
148
+ this.receivedBytes += bytes.length;
149
+ if (this.receivedBytes > this.maxBufferedBytes) {
150
+ this.fail(ws, "hai:ai:058", "buffered audio exceeds limit");
151
+ return;
152
+ }
153
+ if (this.start?.stream)
154
+ this.audioQueue.push(bytes);
155
+ else
156
+ this.audioBuffer.push(bytes);
157
+ return;
158
+ }
159
+ if (typeof data !== "string")
160
+ return;
161
+ if (data.length > this.maxMessageBytes) {
162
+ this.fail(ws, "hai:ai:058", "message too large");
163
+ return;
164
+ }
165
+ let message;
166
+ try {
167
+ message = JSON.parse(data);
168
+ } catch {
169
+ this.fail(ws, "hai:ai:050", "invalid json message");
170
+ return;
171
+ }
172
+ if (message.type === "start") {
173
+ await this.onStart(ws, message);
174
+ } else if (!this.started) {
175
+ this.fail(ws, "hai:ai:050", "message before start");
176
+ } else if (message.type === "text") {
177
+ this.receivedTextBytes += Buffer.byteLength(message.text, "utf8");
178
+ if (this.receivedTextBytes > this.maxTextBytes) {
179
+ this.fail(ws, "hai:ai:058", "buffered text exceeds limit");
180
+ return;
60
181
  }
182
+ this.textQueue.push({ id: message.segmentId, text: message.text });
183
+ } else if (message.type === "done") {
184
+ this.audioQueue.end();
185
+ this.textQueue.end();
186
+ if (this.start && !this.start.stream && this.start.operation === "transcribe")
187
+ await this.runBufferedTranscribe(ws);
188
+ }
189
+ }
190
+ async onStart(ws, message) {
191
+ if (this.started) {
192
+ this.fail(ws, "hai:ai:050", "session already started");
193
+ return;
194
+ }
195
+ if (!this.ticket) {
196
+ this.fail(ws, "hai:ai:054", "missing audio ticket");
197
+ return;
198
+ }
199
+ const verified = await this.deps.verifyTicket(this.ticket);
200
+ if (!verified.success) {
201
+ this.fail(ws, String(verified.error.code), verified.error.message);
202
+ return;
203
+ }
204
+ this.session = verified.data;
205
+ const authorized = this.deps.authorize ? await this.deps.authorize(verified.data, message) : { success: true, data: { operation: message.operation, format: message.format } };
206
+ if (!authorized.success) {
207
+ this.fail(ws, String(authorized.error.code), authorized.error.message);
208
+ return;
209
+ }
210
+ const confirmed = {
211
+ ...message,
212
+ operation: authorized.data.operation,
213
+ model: authorized.data.model,
214
+ voice: authorized.data.voice,
215
+ instruction: authorized.data.instruction,
216
+ format: authorized.data.format,
217
+ sampleRate: authorized.data.sampleRate
61
218
  };
62
- process.once("SIGINT", () => void shutdown());
63
- process.once("SIGTERM", () => void shutdown());
219
+ this.started = true;
220
+ this.start = confirmed;
221
+ this.authorizedRequest = authorized.data;
222
+ if (confirmed.operation === "synthesize")
223
+ void this.runSynthesize(ws, confirmed);
224
+ else if (confirmed.operation === "transcribe" && confirmed.stream)
225
+ void this.runStreamTranscribe(ws, confirmed);
64
226
  }
65
- return handle;
227
+ /** 流式识别:桥接持续音频输入,流式返回临时/最终结果与语音起止事件 */
228
+ async runStreamTranscribe(ws, start) {
229
+ try {
230
+ const stream = this.deps.ai.audio.transcribeStream({
231
+ audio: {
232
+ chunks: this.audioQueue.iterate(),
233
+ format: start.format ?? "pcm16",
234
+ sampleRate: start.sampleRate ?? 16e3,
235
+ channels: start.channels
236
+ },
237
+ language: start.language,
238
+ contextHints: start.contextHints,
239
+ model: start.model,
240
+ signal: this.controller.signal
241
+ });
242
+ for await (const event of stream) {
243
+ if (event.type === "transcript")
244
+ this.send(ws, { type: "transcript", text: event.text, final: event.final });
245
+ else if (event.type === "speech_started")
246
+ this.send(ws, { type: "speech_started" });
247
+ else if (event.type === "speech_stopped")
248
+ this.send(ws, { type: "speech_stopped" });
249
+ }
250
+ this.end(ws);
251
+ } catch (error) {
252
+ this.failFromError(ws, error);
253
+ }
254
+ }
255
+ /** 非流式识别:缓冲完整音频后返回单条最终结果 */
256
+ async runBufferedTranscribe(ws) {
257
+ const start = this.start;
258
+ if (!start)
259
+ return;
260
+ try {
261
+ const result = await this.deps.ai.audio.transcribe({
262
+ audio: {
263
+ data: concat(this.audioBuffer),
264
+ format: start.format ?? "wav",
265
+ sampleRate: start.sampleRate,
266
+ channels: start.channels
267
+ },
268
+ language: start.language,
269
+ contextHints: start.contextHints,
270
+ model: start.model,
271
+ signal: this.controller.signal
272
+ });
273
+ if (!result.success) {
274
+ this.fail(ws, String(result.error.code), result.error.message);
275
+ return;
276
+ }
277
+ this.send(ws, { type: "transcript", text: result.data.text, final: true });
278
+ this.end(ws);
279
+ } catch (error) {
280
+ this.failFromError(ws, error);
281
+ }
282
+ }
283
+ /** 语音合成:桥接文本输入,流式返回音频二进制帧 */
284
+ async runSynthesize(ws, start) {
285
+ try {
286
+ const stream = this.deps.ai.audio.synthesizeStream({
287
+ text: this.textQueue.iterate(),
288
+ voice: start.voice,
289
+ instruction: start.instruction,
290
+ format: start.format,
291
+ sampleRate: start.sampleRate,
292
+ model: start.model,
293
+ signal: this.controller.signal
294
+ });
295
+ for await (const event of stream) {
296
+ if (event.type === "audio")
297
+ ws.send(toArrayBuffer(event.data));
298
+ else
299
+ this.send(ws, event);
300
+ }
301
+ this.end(ws);
302
+ } catch (error) {
303
+ this.failFromError(ws, error);
304
+ }
305
+ }
306
+ fail(ws, code, message) {
307
+ this.send(ws, { type: "error", code, message });
308
+ this.dispose();
309
+ ws.close();
310
+ }
311
+ failFromError(ws, error) {
312
+ const hai = error;
313
+ const code = typeof hai?.code === "string" ? hai.code : "hai:ai:054";
314
+ const message = error instanceof Error ? error.message : String(error);
315
+ this.fail(ws, code, message);
316
+ }
317
+ end(ws) {
318
+ this.send(ws, { type: "end" });
319
+ this.dispose();
320
+ ws.close();
321
+ }
322
+ send(ws, message) {
323
+ if (!this.closed)
324
+ ws.send(JSON.stringify(message));
325
+ }
326
+ dispose() {
327
+ if (this.closed)
328
+ return;
329
+ this.closed = true;
330
+ this.audioQueue.end();
331
+ this.textQueue.end();
332
+ this.controller.abort();
333
+ if (this.session && this.authorizedRequest && this.deps.onSessionEnd) {
334
+ void Promise.resolve(this.deps.onSessionEnd(this.session, this.authorizedRequest)).catch((error) => {
335
+ logger.warn("audio ws session end hook failed", { error: error instanceof Error ? error.message : String(error) });
336
+ });
337
+ }
338
+ }
339
+ };
340
+ function toBytes(data) {
341
+ if (typeof data === "string")
342
+ return void 0;
343
+ if (data instanceof ArrayBuffer)
344
+ return new Uint8Array(data);
345
+ if (ArrayBuffer.isView(data))
346
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
347
+ if (Buffer.isBuffer(data))
348
+ return new Uint8Array(data);
349
+ return void 0;
66
350
  }
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
- });
351
+ function concat(chunks) {
352
+ const total = chunks.reduce((sum, c) => sum + c.length, 0);
353
+ const out = new Uint8Array(total);
354
+ let offset = 0;
355
+ for (const chunk of chunks) {
356
+ out.set(chunk, offset);
357
+ offset += chunk.length;
358
+ }
359
+ return out;
360
+ }
361
+ function toArrayBuffer(data) {
362
+ const buffer = new ArrayBuffer(data.length);
363
+ new Uint8Array(buffer).set(data);
364
+ return buffer;
75
365
  }
76
366
  var DEFAULT_LOCALE = "zh-CN";
77
367
  function resolveRequestLocale(headers) {
@@ -293,7 +583,7 @@ function resolveRefreshCookieConfig(apiPrefix, config, iam) {
293
583
  return {
294
584
  cookieName: config.cookieName ?? "hai_refresh_token",
295
585
  maxAge: config.maxAge ?? 30 * 24 * 3600,
296
- isSecure: config.secure ?? process.env.NODE_ENV === "production",
586
+ isSecure: config.secure ?? process2.env.NODE_ENV === "production",
297
587
  refreshCookiePath: `${apiPrefix}${IAM_AUTH_ROUTES.refresh}`,
298
588
  onRefresh
299
589
  };
@@ -479,7 +769,7 @@ function readScalarBrowserEntry(packageInfo) {
479
769
  const browser = packageInfo.browser;
480
770
  return typeof browser === "string" ? browser : void 0;
481
771
  }
482
- var logger = core.logger.child({ module: "serv", scope: "transport" });
772
+ var logger2 = core.logger.child({ module: "serv", scope: "transport" });
483
773
  var MAX_ENCRYPTED_BODY = 1048576;
484
774
  var ENCRYPTABLE_RESPONSE_CONTENT_TYPES = ["application/json"];
485
775
  function createTransportMiddleware(manager, keyExchangePath, excludePaths = []) {
@@ -520,7 +810,7 @@ function createTransportMiddleware(manager, keyExchangePath, excludePaths = [])
520
810
  try {
521
811
  bodyText = await response.clone().text();
522
812
  } catch (cause) {
523
- logger.warn("Failed to read response body for encryption", { error: cause });
813
+ logger2.warn("Failed to read response body for encryption", { error: cause });
524
814
  c.res = jsonError(servM("serv_transportEncryptFailed"), 500);
525
815
  return;
526
816
  }
@@ -532,7 +822,7 @@ function createTransportMiddleware(manager, keyExchangePath, excludePaths = [])
532
822
  }
533
823
  const encResult = await manager.encryptResponse(clientId, bodyText);
534
824
  if (!encResult.success) {
535
- logger.warn("Failed to encrypt response", { error: encResult.error });
825
+ logger2.warn("Failed to encrypt response", { error: encResult.error });
536
826
  c.res = jsonError(servM("serv_transportEncryptFailed"), 500);
537
827
  return;
538
828
  }
@@ -564,7 +854,7 @@ async function handleKeyExchange(request, manager) {
564
854
  headers: { "Content-Type": "application/json; charset=utf-8" }
565
855
  });
566
856
  } catch (cause) {
567
- logger.error("Key exchange failed", { error: cause });
857
+ logger2.error("Key exchange failed", { error: cause });
568
858
  return jsonError(servM("serv_transportKeyExchangeFailed"), 500);
569
859
  }
570
860
  }
@@ -582,7 +872,7 @@ async function decryptRequestInPlace(c, manager) {
582
872
  return jsonError(servM("serv_transportInvalidPayload"), 400);
583
873
  const decResult = manager.decryptRequest(payload);
584
874
  if (!decResult.success) {
585
- logger.warn("Failed to decrypt request", { error: decResult.error });
875
+ logger2.warn("Failed to decrypt request", { error: decResult.error });
586
876
  return jsonError(servM("serv_transportDecryptFailed"), 400);
587
877
  }
588
878
  const original = c.req.raw;
@@ -638,7 +928,9 @@ function isEncryptedPayloadShape(value) {
638
928
  }
639
929
 
640
930
  // src/serv-app.ts
931
+ var DEFAULT_AUDIO_WS_PATH = "/ai/audio";
641
932
  var API_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
933
+ var audioWsInjectors = /* @__PURE__ */ new WeakMap();
642
934
  function createApp(options) {
643
935
  const http = resolveServHttpConfig(options.http);
644
936
  const app = new Hono();
@@ -672,6 +964,21 @@ function createApp(options) {
672
964
  mountHealthRoutes(app, http.health);
673
965
  if (options.refreshCookie)
674
966
  mountRefreshCookieRoutes(app, http.apiPrefix, options.refreshCookie, options.iam);
967
+ if (options.audio) {
968
+ const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
969
+ const audioPath = `${http.apiPrefix}${options.audio.path ?? DEFAULT_AUDIO_WS_PATH}`;
970
+ registerAudioWsRoute(app, audioPath, upgradeWebSocket, {
971
+ ai: options.audio.ai,
972
+ verifyTicket: options.audio.verifyTicket,
973
+ authorize: options.audio.authorize,
974
+ onSessionEnd: options.audio.onSessionEnd,
975
+ maxMessageBytes: options.audio.maxMessageBytes,
976
+ maxBufferedBytes: options.audio.maxBufferedBytes,
977
+ maxTextBytes: options.audio.maxTextBytes,
978
+ maxSessionMs: options.audio.maxSessionMs
979
+ });
980
+ audioWsInjectors.set(app, injectWebSocket);
981
+ }
675
982
  mountOpenAPIRoutes(app, options.procedures, http.apiPrefix, createContext);
676
983
  if (http.rpc !== false)
677
984
  mountRPCRoutes(app, options.procedures, http.rpc, createContext);
@@ -779,6 +1086,61 @@ function isOrpcValidationErrorPayload(value) {
779
1086
  return Array.isArray(issues);
780
1087
  }
781
1088
 
1089
+ // src/adapters/serv-adapter-node.ts
1090
+ var DEFAULT_SERV_HOST = "127.0.0.1";
1091
+ var DEFAULT_SERV_PORT = 3e3;
1092
+ function listen(app, options = {}) {
1093
+ const host = options.host ?? process2.env.HOST ?? DEFAULT_SERV_HOST;
1094
+ const port = options.port ?? (process2.env.PORT ? Number(process2.env.PORT) : DEFAULT_SERV_PORT);
1095
+ const server = serve(
1096
+ { fetch: app.fetch, hostname: host, port },
1097
+ options.onListening
1098
+ );
1099
+ const injectWebSocket = audioWsInjectors.get(app);
1100
+ if (injectWebSocket)
1101
+ injectWebSocket(server);
1102
+ const handle = {
1103
+ server,
1104
+ address: { host, port },
1105
+ close: () => new Promise((resolve, reject) => {
1106
+ server.close((error) => {
1107
+ if (error)
1108
+ reject(error);
1109
+ else resolve();
1110
+ });
1111
+ })
1112
+ };
1113
+ if (options.onClose) {
1114
+ const onClose = options.onClose;
1115
+ let shuttingDown = false;
1116
+ const shutdown = async () => {
1117
+ if (shuttingDown)
1118
+ return;
1119
+ shuttingDown = true;
1120
+ try {
1121
+ await handle.close();
1122
+ } catch {
1123
+ }
1124
+ try {
1125
+ await onClose();
1126
+ } catch {
1127
+ }
1128
+ };
1129
+ process2.once("SIGINT", () => void shutdown());
1130
+ process2.once("SIGTERM", () => void shutdown());
1131
+ }
1132
+ return handle;
1133
+ }
1134
+ var WILDCARD_ROLE = "*";
1135
+ function requireRole(role, handler) {
1136
+ return requireAuth(async (options) => {
1137
+ const roles = options.context.session?.roles ?? [];
1138
+ if (!roles.includes(role) && !roles.includes(WILDCARD_ROLE))
1139
+ return err(HaiCommonError.FORBIDDEN, servM("serv_errorForbidden", { locale: options.context.locale }));
1140
+ return handler(options);
1141
+ });
1142
+ }
1143
+
782
1144
  // src/serv-cors.ts
783
1145
  var DEFAULT_CORS_METHODS = ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE", "OPTIONS"];
784
1146
  var DEFAULT_CORS_ALLOWED_HEADERS = ["Authorization", "Content-Type", "X-Requested-With"];