@h-ai/serv 0.1.0-alpha.35 → 0.1.0-alpha.37

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
@@ -3,7 +3,7 @@ import { servM, mapHaiError, requireAuth, normalizeServLocale, buildHaiErrorBody
3
3
  import { implement } from '@orpc/server';
4
4
  import process2 from 'process';
5
5
  import { serve } from '@hono/node-server';
6
- import { AUDIO_WS_PATH } from '@h-ai/ai';
6
+ import { AUDIO_WS_PATH, IAM_AUTH_ROUTES, AudioWsClientMessageSchema } from '@h-ai/api-contract';
7
7
  import { core, ok, err, HaiCommonError } from '@h-ai/core';
8
8
  import { createNodeWebSocket } from '@hono/node-ws';
9
9
  import { OpenAPIHandler } from '@orpc/openapi/fetch';
@@ -11,7 +11,6 @@ import { RPCHandler } from '@orpc/server/fetch';
11
11
  import { Hono } from 'hono';
12
12
  import { Buffer } from 'buffer';
13
13
  import { z } from 'zod';
14
- import { IAM_AUTH_ROUTES } from '@h-ai/api-contract';
15
14
  import { getCookie } from 'hono/cookie';
16
15
  import { readFile } from 'fs/promises';
17
16
  import { OpenAPIGenerator } from '@orpc/openapi';
@@ -29,25 +28,39 @@ function toFetch(app) {
29
28
  var logger = core.logger.child({ module: "serv", scope: "audio-ws" });
30
29
  var DEFAULT_MAX_MESSAGE_BYTES = 1048576;
31
30
  var DEFAULT_MAX_BUFFERED_BYTES = 10 * 1024 * 1024;
31
+ var DEFAULT_MAX_TEXT_BYTES = 1 * 1024 * 1024;
32
32
  var DEFAULT_MAX_SESSION_MS = 5 * 60 * 1e3;
33
+ var DEFAULT_PRE_AUTH_TIMEOUT_MS = 5e3;
34
+ var DEFAULT_MAX_PENDING_MESSAGES = 256;
35
+ var DEFAULT_MAX_SEND_BUFFER_BYTES = 8 * 1024 * 1024;
36
+ var MAX_QUEUE_ITEMS = 4096;
37
+ var CODE_PROTOCOL = "hai:ai:050";
38
+ var CODE_TICKET = "hai:ai:054";
39
+ var CODE_TOO_LARGE = "hai:ai:058";
40
+ var CODE_TIMEOUT = "hai:ai:057";
33
41
  function registerAudioWsRoute(app, path, upgradeWebSocket, deps) {
34
- const maxMessageBytes = deps.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES;
35
- const maxBufferedBytes = deps.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES;
42
+ const limits = {
43
+ maxMessageBytes: deps.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES,
44
+ maxBufferedBytes: deps.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES,
45
+ maxTextBytes: deps.maxTextBytes ?? DEFAULT_MAX_TEXT_BYTES,
46
+ maxPendingMessages: deps.maxPendingMessages ?? DEFAULT_MAX_PENDING_MESSAGES,
47
+ maxSendBufferBytes: deps.maxSendBufferBytes ?? DEFAULT_MAX_SEND_BUFFER_BYTES
48
+ };
36
49
  const maxSessionMs = deps.maxSessionMs ?? DEFAULT_MAX_SESSION_MS;
50
+ const preAuthTimeoutMs = deps.preAuthTimeoutMs ?? DEFAULT_PRE_AUTH_TIMEOUT_MS;
37
51
  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);
52
+ const ticket = c.req.query("ticket");
53
+ const conn = new AudioConnection(deps, ticket, limits);
40
54
  let sessionTimer;
41
55
  return {
42
56
  onOpen(_evt, ws) {
57
+ conn.begin(ws, preAuthTimeoutMs);
43
58
  sessionTimer = setTimeout(() => {
44
- conn.fail(ws, "hai:ai:057", "audio session timeout");
59
+ conn.fail(ws, CODE_TIMEOUT, "audio session timeout");
45
60
  }, maxSessionMs);
46
61
  },
47
62
  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
- });
63
+ conn.enqueue(ws, evt.data);
51
64
  },
52
65
  onClose() {
53
66
  if (sessionTimer)
@@ -63,19 +76,26 @@ function registerAudioWsRoute(app, path, upgradeWebSocket, deps) {
63
76
  }));
64
77
  }
65
78
  var AsyncQueue = class {
79
+ constructor(maxItems) {
80
+ this.maxItems = maxItems;
81
+ }
66
82
  items = [];
67
83
  waiting = null;
68
84
  ended = false;
85
+ /** 入队;超过上限返回 false(背压信号,调用方据此关闭连接)。 */
69
86
  push(item) {
70
87
  if (this.ended)
71
- return;
88
+ return true;
72
89
  if (this.waiting) {
73
90
  const w = this.waiting;
74
91
  this.waiting = null;
75
92
  w({ value: item, done: false });
76
- } else {
77
- this.items.push(item);
93
+ return true;
78
94
  }
95
+ if (this.items.length >= this.maxItems)
96
+ return false;
97
+ this.items.push(item);
98
+ return true;
79
99
  }
80
100
  end() {
81
101
  if (this.ended)
@@ -105,92 +125,230 @@ var AsyncQueue = class {
105
125
  }
106
126
  };
107
127
  var AudioConnection = class {
108
- constructor(deps, token, maxMessageBytes, maxBufferedBytes) {
128
+ constructor(deps, ticket, limits) {
109
129
  this.deps = deps;
110
- this.token = token;
111
- this.maxMessageBytes = maxMessageBytes;
112
- this.maxBufferedBytes = maxBufferedBytes;
130
+ this.ticket = ticket;
131
+ this.limits = limits;
132
+ this.audioQueue = new AsyncQueue(MAX_QUEUE_ITEMS);
133
+ this.textQueue = new AsyncQueue(MAX_QUEUE_ITEMS);
113
134
  }
114
- started = false;
135
+ state = "connecting";
115
136
  start;
116
- audioQueue = new AsyncQueue();
117
- textQueue = new AsyncQueue();
137
+ session;
138
+ grant;
139
+ authorizedRequest;
140
+ doneReceived = false;
141
+ audioQueue;
142
+ textQueue;
118
143
  audioBuffer = [];
119
144
  receivedBytes = 0;
145
+ receivedTextBytes = 0;
146
+ /** 合成文本段 ID 去重(同一会话内唯一)。 */
147
+ seenSegmentIds = /* @__PURE__ */ new Set();
120
148
  controller = new AbortController();
121
149
  closed = false;
150
+ preAuthTimer;
151
+ /** 待处理消息数(含鉴权任务):积压过多即关闭。 */
152
+ pending = 0;
153
+ /** 消息串行处理链:确保同一连接的消息按到达顺序处理,鉴权完成前不处理业务帧。 */
154
+ queue = Promise.resolve();
155
+ /** 连接建立:立即鉴权(串行链首个任务)并启动预鉴权超时。 */
156
+ begin(ws, preAuthTimeoutMs) {
157
+ this.preAuthTimer = setTimeout(() => {
158
+ if (this.state === "connecting" || this.state === "authenticated" || this.state === "awaiting_start")
159
+ this.fail(ws, CODE_TIMEOUT, "pre-auth timeout: authenticate and send start in time");
160
+ }, preAuthTimeoutMs);
161
+ this.chain(ws, () => this.authenticate(ws));
162
+ }
163
+ /** 将任务追加到串行处理链(含积压上限保护)。 */
164
+ chain(ws, task) {
165
+ if (this.closed)
166
+ return;
167
+ this.pending++;
168
+ if (this.pending > this.limits.maxPendingMessages) {
169
+ this.pending--;
170
+ this.fail(ws, CODE_TOO_LARGE, "too many pending messages");
171
+ return;
172
+ }
173
+ this.queue = this.queue.then(() => task()).catch((error) => {
174
+ logger.debug("audio ws task failed", { error: error instanceof Error ? error.message : String(error) });
175
+ if (!this.closed)
176
+ this.failFromError(ws, error);
177
+ }).finally(() => {
178
+ this.pending--;
179
+ });
180
+ }
181
+ /** 将消息追加到串行处理链,保证按序处理(鉴权完成前不处理业务帧)。 */
182
+ enqueue(ws, data) {
183
+ this.chain(ws, () => this.handleMessage(ws, data));
184
+ }
185
+ /** 连接建立即执行:校验并原子消费 ticket,成功后进入 awaiting_start。 */
186
+ async authenticate(ws) {
187
+ if (this.closed)
188
+ return;
189
+ if (!this.ticket) {
190
+ this.fail(ws, CODE_TICKET, "missing audio ticket");
191
+ return;
192
+ }
193
+ const verified = await this.deps.verifyTicket(this.ticket);
194
+ if (this.closed)
195
+ return;
196
+ if (!verified.success) {
197
+ this.fail(ws, String(verified.error.code), verified.error.message);
198
+ return;
199
+ }
200
+ this.session = verified.data.session;
201
+ this.grant = verified.data.grant;
202
+ this.state = "awaiting_start";
203
+ }
122
204
  async handleMessage(ws, data) {
123
205
  if (this.closed)
124
206
  return;
125
207
  const bytes = toBytes(data);
126
208
  if (bytes) {
127
- if (!this.started) {
128
- this.fail(ws, "hai:ai:050", "audio frame before start");
129
- return;
130
- }
131
- if (bytes.length > this.maxMessageBytes) {
132
- this.fail(ws, "hai:ai:058", "audio frame too large");
133
- return;
134
- }
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);
209
+ this.handleAudioFrame(ws, bytes);
144
210
  return;
145
211
  }
146
212
  if (typeof data !== "string")
147
213
  return;
148
- if (data.length > this.maxMessageBytes) {
149
- this.fail(ws, "hai:ai:058", "message too large");
214
+ if (Buffer.byteLength(data, "utf8") > this.limits.maxMessageBytes) {
215
+ this.fail(ws, CODE_TOO_LARGE, "message too large");
150
216
  return;
151
217
  }
152
- let message;
218
+ let raw;
153
219
  try {
154
- message = JSON.parse(data);
220
+ raw = JSON.parse(data);
155
221
  } catch {
156
- this.fail(ws, "hai:ai:050", "invalid json message");
222
+ this.fail(ws, CODE_PROTOCOL, "invalid json message");
223
+ return;
224
+ }
225
+ const parsed = AudioWsClientMessageSchema.safeParse(raw);
226
+ if (!parsed.success) {
227
+ this.fail(ws, CODE_PROTOCOL, `invalid message: ${parsed.error.issues[0]?.message ?? "schema validation failed"}`);
157
228
  return;
158
229
  }
230
+ const message = parsed.data;
159
231
  if (message.type === "start") {
160
232
  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);
233
+ return;
234
+ }
235
+ if (this.state === "connecting" || this.state === "authenticated" || this.state === "awaiting_start") {
236
+ this.fail(ws, CODE_PROTOCOL, "message before start");
237
+ return;
238
+ }
239
+ if (message.type === "text")
240
+ this.handleTextFrame(ws, message);
241
+ else if (message.type === "done")
242
+ this.handleDone(ws);
243
+ }
244
+ /** 处理音频二进制帧(仅识别操作、输入未结束时接受)。 */
245
+ handleAudioFrame(ws, bytes) {
246
+ if (this.state !== "transcribing") {
247
+ this.fail(ws, CODE_PROTOCOL, "unexpected audio frame");
248
+ return;
249
+ }
250
+ if (bytes.length > this.limits.maxMessageBytes) {
251
+ this.fail(ws, CODE_TOO_LARGE, "audio frame too large");
252
+ return;
253
+ }
254
+ this.receivedBytes += bytes.length;
255
+ if (this.receivedBytes > this.limits.maxBufferedBytes) {
256
+ this.fail(ws, CODE_TOO_LARGE, "buffered audio exceeds limit");
257
+ return;
258
+ }
259
+ if (this.start?.stream) {
260
+ if (!this.audioQueue.push(bytes))
261
+ this.fail(ws, CODE_TOO_LARGE, "audio backpressure limit exceeded");
262
+ } else {
263
+ this.audioBuffer.push(bytes);
170
264
  }
171
265
  }
266
+ /** 处理文本控制帧(仅合成操作、输入未结束时接受;segmentId 唯一)。 */
267
+ handleTextFrame(ws, message) {
268
+ if (this.state !== "synthesizing") {
269
+ this.fail(ws, CODE_PROTOCOL, "unexpected text frame");
270
+ return;
271
+ }
272
+ if (this.seenSegmentIds.has(message.segmentId)) {
273
+ this.fail(ws, CODE_PROTOCOL, `duplicate segmentId: ${message.segmentId}`);
274
+ return;
275
+ }
276
+ this.receivedTextBytes += Buffer.byteLength(message.text, "utf8");
277
+ if (this.receivedTextBytes > this.limits.maxTextBytes) {
278
+ this.fail(ws, CODE_TOO_LARGE, "buffered text exceeds limit");
279
+ return;
280
+ }
281
+ this.seenSegmentIds.add(message.segmentId);
282
+ if (!this.textQueue.push({ id: message.segmentId, text: message.text }))
283
+ this.fail(ws, CODE_TOO_LARGE, "text backpressure limit exceeded");
284
+ }
285
+ /** 处理输入结束帧(只允许一次)。 */
286
+ handleDone(ws) {
287
+ if (this.doneReceived) {
288
+ this.fail(ws, CODE_PROTOCOL, "duplicate done");
289
+ return;
290
+ }
291
+ this.doneReceived = true;
292
+ this.audioQueue.end();
293
+ this.textQueue.end();
294
+ if (this.state === "transcribing" || this.state === "synthesizing")
295
+ this.state = "input_completed";
296
+ if (this.start && !this.start.stream && this.start.operation === "transcribe")
297
+ void this.runBufferedTranscribe(ws);
298
+ }
172
299
  async onStart(ws, message) {
173
- if (this.started) {
174
- this.fail(ws, "hai:ai:050", "session already started");
300
+ if (this.state === "connecting" || this.state === "authenticated") {
301
+ this.fail(ws, CODE_TICKET, "not authenticated");
175
302
  return;
176
303
  }
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
- }
304
+ if (this.state !== "awaiting_start") {
305
+ this.fail(ws, CODE_PROTOCOL, "session already started");
306
+ return;
307
+ }
308
+ const session = this.session;
309
+ if (!session) {
310
+ this.fail(ws, CODE_TICKET, "not authenticated");
311
+ return;
312
+ }
313
+ const authorized = this.deps.authorize ? await this.deps.authorize(session, message, this.grant) : this.defaultAuthorize(message);
314
+ if (this.closed)
315
+ return;
316
+ if (!authorized.success) {
317
+ this.fail(ws, String(authorized.error.code), authorized.error.message);
318
+ return;
319
+ }
320
+ const confirmed = {
321
+ ...message,
322
+ operation: authorized.data.operation,
323
+ model: authorized.data.model,
324
+ voice: authorized.data.voice,
325
+ instruction: authorized.data.instruction,
326
+ format: authorized.data.format,
327
+ sampleRate: authorized.data.sampleRate
328
+ };
329
+ this.start = confirmed;
330
+ this.authorizedRequest = authorized.data;
331
+ this.state = confirmed.operation === "synthesize" ? "synthesizing" : "transcribing";
332
+ if (this.preAuthTimer) {
333
+ clearTimeout(this.preAuthTimer);
334
+ this.preAuthTimer = void 0;
189
335
  }
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);
336
+ if (confirmed.operation === "synthesize")
337
+ void this.runSynthesize(ws, confirmed);
338
+ else if (confirmed.operation === "transcribe" && confirmed.stream)
339
+ void this.runStreamTranscribe(ws, confirmed);
340
+ }
341
+ /** 无 authorize 回调时的默认授权:仅保留操作与格式,并交叉校验票据 grant。 */
342
+ defaultAuthorize(message) {
343
+ if (this.grant?.operation && this.grant.operation !== message.operation) {
344
+ return err(HaiCommonError.FORBIDDEN, "operation not permitted by ticket");
345
+ }
346
+ return ok({
347
+ operation: message.operation,
348
+ // 无 authorize 回调时不透传客户端 model/voice/instruction;仅采用票据 grant 绑定的 model
349
+ model: this.grant?.model,
350
+ format: message.format
351
+ });
194
352
  }
195
353
  /** 流式识别:桥接持续音频输入,流式返回临时/最终结果与语音起止事件 */
196
354
  async runStreamTranscribe(ws, start) {
@@ -208,6 +366,8 @@ var AudioConnection = class {
208
366
  signal: this.controller.signal
209
367
  });
210
368
  for await (const event of stream) {
369
+ if (this.closed)
370
+ return;
211
371
  if (event.type === "transcript")
212
372
  this.send(ws, { type: "transcript", text: event.text, final: event.final });
213
373
  else if (event.type === "speech_started")
@@ -238,6 +398,8 @@ var AudioConnection = class {
238
398
  model: start.model,
239
399
  signal: this.controller.signal
240
400
  });
401
+ if (this.closed)
402
+ return;
241
403
  if (!result.success) {
242
404
  this.fail(ws, String(result.error.code), result.error.message);
243
405
  return;
@@ -248,7 +410,7 @@ var AudioConnection = class {
248
410
  this.failFromError(ws, error);
249
411
  }
250
412
  }
251
- /** 语音合成:桥接文本输入,流式返回音频二进制帧 */
413
+ /** 语音合成:桥接文本输入,流式返回音频二进制帧(带发送缓冲背压) */
252
414
  async runSynthesize(ws, start) {
253
415
  try {
254
416
  const stream = this.deps.ai.audio.synthesizeStream({
@@ -260,25 +422,44 @@ var AudioConnection = class {
260
422
  model: start.model,
261
423
  signal: this.controller.signal
262
424
  });
263
- for await (const audio of stream)
264
- ws.send(toArrayBuffer(audio));
425
+ for await (const event of stream) {
426
+ if (this.closed)
427
+ return;
428
+ if (event.type === "audio") {
429
+ if (sendBufferedAmount(ws) > this.limits.maxSendBufferBytes) {
430
+ this.fail(ws, CODE_TOO_LARGE, "client send buffer exceeded high-water mark");
431
+ return;
432
+ }
433
+ ws.send(toArrayBuffer(event.data));
434
+ } else {
435
+ this.send(ws, event);
436
+ }
437
+ }
265
438
  this.end(ws);
266
439
  } catch (error) {
267
440
  this.failFromError(ws, error);
268
441
  }
269
442
  }
270
443
  fail(ws, code, message) {
444
+ if (this.closed)
445
+ return;
446
+ this.state = "failed";
271
447
  this.send(ws, { type: "error", code, message });
272
448
  this.dispose();
273
449
  ws.close();
274
450
  }
275
451
  failFromError(ws, error) {
452
+ if (this.closed)
453
+ return;
276
454
  const hai = error;
277
- const code = typeof hai?.code === "string" ? hai.code : "hai:ai:054";
455
+ const code = typeof hai?.code === "string" ? hai.code : CODE_TICKET;
278
456
  const message = error instanceof Error ? error.message : String(error);
279
457
  this.fail(ws, code, message);
280
458
  }
281
459
  end(ws) {
460
+ if (this.closed)
461
+ return;
462
+ this.state = "completed";
282
463
  this.send(ws, { type: "end" });
283
464
  this.dispose();
284
465
  ws.close();
@@ -291,11 +472,26 @@ var AudioConnection = class {
291
472
  if (this.closed)
292
473
  return;
293
474
  this.closed = true;
475
+ if (this.state !== "completed" && this.state !== "failed")
476
+ this.state = "cancelled";
477
+ if (this.preAuthTimer) {
478
+ clearTimeout(this.preAuthTimer);
479
+ this.preAuthTimer = void 0;
480
+ }
294
481
  this.audioQueue.end();
295
482
  this.textQueue.end();
296
483
  this.controller.abort();
484
+ if (this.session && this.authorizedRequest && this.deps.onSessionEnd) {
485
+ void Promise.resolve(this.deps.onSessionEnd(this.session, this.authorizedRequest)).catch((error) => {
486
+ logger.warn("audio ws session end hook failed", { error: error instanceof Error ? error.message : String(error) });
487
+ });
488
+ }
297
489
  }
298
490
  };
491
+ function sendBufferedAmount(ws) {
492
+ const raw = ws.raw;
493
+ return typeof raw?.bufferedAmount === "number" ? raw.bufferedAmount : 0;
494
+ }
299
495
  function toBytes(data) {
300
496
  if (typeof data === "string")
301
497
  return void 0;
@@ -927,10 +1123,16 @@ function createApp(options) {
927
1123
  const audioPath = `${http.apiPrefix}${options.audio.path ?? AUDIO_WS_PATH}`;
928
1124
  registerAudioWsRoute(app, audioPath, upgradeWebSocket, {
929
1125
  ai: options.audio.ai,
930
- verifyToken,
1126
+ verifyTicket: options.audio.verifyTicket,
1127
+ authorize: options.audio.authorize,
1128
+ onSessionEnd: options.audio.onSessionEnd,
931
1129
  maxMessageBytes: options.audio.maxMessageBytes,
932
1130
  maxBufferedBytes: options.audio.maxBufferedBytes,
933
- maxSessionMs: options.audio.maxSessionMs
1131
+ maxTextBytes: options.audio.maxTextBytes,
1132
+ maxSessionMs: options.audio.maxSessionMs,
1133
+ preAuthTimeoutMs: options.audio.preAuthTimeoutMs,
1134
+ maxPendingMessages: options.audio.maxPendingMessages,
1135
+ maxSendBufferBytes: options.audio.maxSendBufferBytes
934
1136
  });
935
1137
  audioWsInjectors.set(app, injectWebSocket);
936
1138
  }