@h-ai/serv 0.1.0-alpha.36 → 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,6 +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, IAM_AUTH_ROUTES, AudioWsClientMessageSchema } from '@h-ai/api-contract';
6
7
  import { core, ok, err, HaiCommonError } from '@h-ai/core';
7
8
  import { createNodeWebSocket } from '@hono/node-ws';
8
9
  import { OpenAPIHandler } from '@orpc/openapi/fetch';
@@ -10,7 +11,6 @@ import { RPCHandler } from '@orpc/server/fetch';
10
11
  import { Hono } from 'hono';
11
12
  import { Buffer } from 'buffer';
12
13
  import { z } from 'zod';
13
- import { IAM_AUTH_ROUTES } from '@h-ai/api-contract';
14
14
  import { getCookie } from 'hono/cookie';
15
15
  import { readFile } from 'fs/promises';
16
16
  import { OpenAPIGenerator } from '@orpc/openapi';
@@ -30,19 +30,33 @@ var DEFAULT_MAX_MESSAGE_BYTES = 1048576;
30
30
  var DEFAULT_MAX_BUFFERED_BYTES = 10 * 1024 * 1024;
31
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;
36
- const maxTextBytes = deps.maxTextBytes ?? DEFAULT_MAX_TEXT_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
+ };
37
49
  const maxSessionMs = deps.maxSessionMs ?? DEFAULT_MAX_SESSION_MS;
50
+ const preAuthTimeoutMs = deps.preAuthTimeoutMs ?? DEFAULT_PRE_AUTH_TIMEOUT_MS;
38
51
  app.get(path, upgradeWebSocket((c) => {
39
52
  const ticket = c.req.query("ticket");
40
- const conn = new AudioConnection(deps, ticket, maxMessageBytes, maxBufferedBytes, maxTextBytes);
53
+ const conn = new AudioConnection(deps, ticket, limits);
41
54
  let sessionTimer;
42
55
  return {
43
56
  onOpen(_evt, ws) {
57
+ conn.begin(ws, preAuthTimeoutMs);
44
58
  sessionTimer = setTimeout(() => {
45
- conn.fail(ws, "hai:ai:057", "audio session timeout");
59
+ conn.fail(ws, CODE_TIMEOUT, "audio session timeout");
46
60
  }, maxSessionMs);
47
61
  },
48
62
  onMessage(evt, ws) {
@@ -62,19 +76,26 @@ function registerAudioWsRoute(app, path, upgradeWebSocket, deps) {
62
76
  }));
63
77
  }
64
78
  var AsyncQueue = class {
79
+ constructor(maxItems) {
80
+ this.maxItems = maxItems;
81
+ }
65
82
  items = [];
66
83
  waiting = null;
67
84
  ended = false;
85
+ /** 入队;超过上限返回 false(背压信号,调用方据此关闭连接)。 */
68
86
  push(item) {
69
87
  if (this.ended)
70
- return;
88
+ return true;
71
89
  if (this.waiting) {
72
90
  const w = this.waiting;
73
91
  this.waiting = null;
74
92
  w({ value: item, done: false });
75
- } else {
76
- this.items.push(item);
93
+ return true;
77
94
  }
95
+ if (this.items.length >= this.maxItems)
96
+ return false;
97
+ this.items.push(item);
98
+ return true;
78
99
  }
79
100
  end() {
80
101
  if (this.ended)
@@ -104,105 +125,194 @@ var AsyncQueue = class {
104
125
  }
105
126
  };
106
127
  var AudioConnection = class {
107
- constructor(deps, ticket, maxMessageBytes, maxBufferedBytes, maxTextBytes) {
128
+ constructor(deps, ticket, limits) {
108
129
  this.deps = deps;
109
130
  this.ticket = ticket;
110
- this.maxMessageBytes = maxMessageBytes;
111
- this.maxBufferedBytes = maxBufferedBytes;
112
- this.maxTextBytes = maxTextBytes;
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
137
  session;
138
+ grant;
117
139
  authorizedRequest;
118
- audioQueue = new AsyncQueue();
119
- textQueue = new AsyncQueue();
140
+ doneReceived = false;
141
+ audioQueue;
142
+ textQueue;
120
143
  audioBuffer = [];
121
144
  receivedBytes = 0;
122
145
  receivedTextBytes = 0;
146
+ /** 合成文本段 ID 去重(同一会话内唯一)。 */
147
+ seenSegmentIds = /* @__PURE__ */ new Set();
123
148
  controller = new AbortController();
124
149
  closed = false;
150
+ preAuthTimer;
151
+ /** 待处理消息数(含鉴权任务):积压过多即关闭。 */
152
+ pending = 0;
125
153
  /** 消息串行处理链:确保同一连接的消息按到达顺序处理,鉴权完成前不处理业务帧。 */
126
154
  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) });
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) });
131
175
  if (!this.closed)
132
176
  this.failFromError(ws, error);
177
+ }).finally(() => {
178
+ this.pending--;
133
179
  });
134
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
+ }
135
204
  async handleMessage(ws, data) {
136
205
  if (this.closed)
137
206
  return;
138
207
  const bytes = toBytes(data);
139
208
  if (bytes) {
140
- if (!this.started) {
141
- this.fail(ws, "hai:ai:050", "audio frame before start");
142
- return;
143
- }
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);
209
+ this.handleAudioFrame(ws, bytes);
157
210
  return;
158
211
  }
159
212
  if (typeof data !== "string")
160
213
  return;
161
- if (data.length > this.maxMessageBytes) {
162
- 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");
163
216
  return;
164
217
  }
165
- let message;
218
+ let raw;
166
219
  try {
167
- message = JSON.parse(data);
220
+ raw = JSON.parse(data);
168
221
  } catch {
169
- this.fail(ws, "hai:ai:050", "invalid json message");
222
+ this.fail(ws, CODE_PROTOCOL, "invalid json message");
170
223
  return;
171
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"}`);
228
+ return;
229
+ }
230
+ const message = parsed.data;
172
231
  if (message.type === "start") {
173
232
  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;
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);
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);
264
+ }
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;
188
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);
189
298
  }
190
299
  async onStart(ws, message) {
191
- if (this.started) {
192
- 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");
193
302
  return;
194
303
  }
195
- if (!this.ticket) {
196
- this.fail(ws, "hai:ai:054", "missing audio ticket");
304
+ if (this.state !== "awaiting_start") {
305
+ this.fail(ws, CODE_PROTOCOL, "session already started");
197
306
  return;
198
307
  }
199
- const verified = await this.deps.verifyTicket(this.ticket);
200
- if (!verified.success) {
201
- this.fail(ws, String(verified.error.code), verified.error.message);
308
+ const session = this.session;
309
+ if (!session) {
310
+ this.fail(ws, CODE_TICKET, "not authenticated");
202
311
  return;
203
312
  }
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 } };
313
+ const authorized = this.deps.authorize ? await this.deps.authorize(session, message, this.grant) : this.defaultAuthorize(message);
314
+ if (this.closed)
315
+ return;
206
316
  if (!authorized.success) {
207
317
  this.fail(ws, String(authorized.error.code), authorized.error.message);
208
318
  return;
@@ -216,14 +326,30 @@ var AudioConnection = class {
216
326
  format: authorized.data.format,
217
327
  sampleRate: authorized.data.sampleRate
218
328
  };
219
- this.started = true;
220
329
  this.start = confirmed;
221
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;
335
+ }
222
336
  if (confirmed.operation === "synthesize")
223
337
  void this.runSynthesize(ws, confirmed);
224
338
  else if (confirmed.operation === "transcribe" && confirmed.stream)
225
339
  void this.runStreamTranscribe(ws, confirmed);
226
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
+ });
352
+ }
227
353
  /** 流式识别:桥接持续音频输入,流式返回临时/最终结果与语音起止事件 */
228
354
  async runStreamTranscribe(ws, start) {
229
355
  try {
@@ -240,6 +366,8 @@ var AudioConnection = class {
240
366
  signal: this.controller.signal
241
367
  });
242
368
  for await (const event of stream) {
369
+ if (this.closed)
370
+ return;
243
371
  if (event.type === "transcript")
244
372
  this.send(ws, { type: "transcript", text: event.text, final: event.final });
245
373
  else if (event.type === "speech_started")
@@ -270,6 +398,8 @@ var AudioConnection = class {
270
398
  model: start.model,
271
399
  signal: this.controller.signal
272
400
  });
401
+ if (this.closed)
402
+ return;
273
403
  if (!result.success) {
274
404
  this.fail(ws, String(result.error.code), result.error.message);
275
405
  return;
@@ -280,7 +410,7 @@ var AudioConnection = class {
280
410
  this.failFromError(ws, error);
281
411
  }
282
412
  }
283
- /** 语音合成:桥接文本输入,流式返回音频二进制帧 */
413
+ /** 语音合成:桥接文本输入,流式返回音频二进制帧(带发送缓冲背压) */
284
414
  async runSynthesize(ws, start) {
285
415
  try {
286
416
  const stream = this.deps.ai.audio.synthesizeStream({
@@ -293,10 +423,17 @@ var AudioConnection = class {
293
423
  signal: this.controller.signal
294
424
  });
295
425
  for await (const event of stream) {
296
- if (event.type === "audio")
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
+ }
297
433
  ws.send(toArrayBuffer(event.data));
298
- else
434
+ } else {
299
435
  this.send(ws, event);
436
+ }
300
437
  }
301
438
  this.end(ws);
302
439
  } catch (error) {
@@ -304,17 +441,25 @@ var AudioConnection = class {
304
441
  }
305
442
  }
306
443
  fail(ws, code, message) {
444
+ if (this.closed)
445
+ return;
446
+ this.state = "failed";
307
447
  this.send(ws, { type: "error", code, message });
308
448
  this.dispose();
309
449
  ws.close();
310
450
  }
311
451
  failFromError(ws, error) {
452
+ if (this.closed)
453
+ return;
312
454
  const hai = error;
313
- const code = typeof hai?.code === "string" ? hai.code : "hai:ai:054";
455
+ const code = typeof hai?.code === "string" ? hai.code : CODE_TICKET;
314
456
  const message = error instanceof Error ? error.message : String(error);
315
457
  this.fail(ws, code, message);
316
458
  }
317
459
  end(ws) {
460
+ if (this.closed)
461
+ return;
462
+ this.state = "completed";
318
463
  this.send(ws, { type: "end" });
319
464
  this.dispose();
320
465
  ws.close();
@@ -327,6 +472,12 @@ var AudioConnection = class {
327
472
  if (this.closed)
328
473
  return;
329
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
+ }
330
481
  this.audioQueue.end();
331
482
  this.textQueue.end();
332
483
  this.controller.abort();
@@ -337,6 +488,10 @@ var AudioConnection = class {
337
488
  }
338
489
  }
339
490
  };
491
+ function sendBufferedAmount(ws) {
492
+ const raw = ws.raw;
493
+ return typeof raw?.bufferedAmount === "number" ? raw.bufferedAmount : 0;
494
+ }
340
495
  function toBytes(data) {
341
496
  if (typeof data === "string")
342
497
  return void 0;
@@ -928,7 +1083,6 @@ function isEncryptedPayloadShape(value) {
928
1083
  }
929
1084
 
930
1085
  // src/serv-app.ts
931
- var DEFAULT_AUDIO_WS_PATH = "/ai/audio";
932
1086
  var API_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
933
1087
  var audioWsInjectors = /* @__PURE__ */ new WeakMap();
934
1088
  function createApp(options) {
@@ -966,7 +1120,7 @@ function createApp(options) {
966
1120
  mountRefreshCookieRoutes(app, http.apiPrefix, options.refreshCookie, options.iam);
967
1121
  if (options.audio) {
968
1122
  const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
969
- const audioPath = `${http.apiPrefix}${options.audio.path ?? DEFAULT_AUDIO_WS_PATH}`;
1123
+ const audioPath = `${http.apiPrefix}${options.audio.path ?? AUDIO_WS_PATH}`;
970
1124
  registerAudioWsRoute(app, audioPath, upgradeWebSocket, {
971
1125
  ai: options.audio.ai,
972
1126
  verifyTicket: options.audio.verifyTicket,
@@ -975,7 +1129,10 @@ function createApp(options) {
975
1129
  maxMessageBytes: options.audio.maxMessageBytes,
976
1130
  maxBufferedBytes: options.audio.maxBufferedBytes,
977
1131
  maxTextBytes: options.audio.maxTextBytes,
978
- maxSessionMs: options.audio.maxSessionMs
1132
+ maxSessionMs: options.audio.maxSessionMs,
1133
+ preAuthTimeoutMs: options.audio.preAuthTimeoutMs,
1134
+ maxPendingMessages: options.audio.maxPendingMessages,
1135
+ maxSendBufferBytes: options.audio.maxSendBufferBytes
979
1136
  });
980
1137
  audioWsInjectors.set(app, injectWebSocket);
981
1138
  }