@dev-anywhere/relay 0.3.14 → 0.4.3

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.
Files changed (40) hide show
  1. package/dist/chunk-OJABHE5C.js +3359 -0
  2. package/dist/chunk-OJABHE5C.js.map +1 -0
  3. package/dist/handlers/client.d.ts +3 -1
  4. package/dist/handlers/client.d.ts.map +1 -1
  5. package/dist/handlers/proxy.d.ts.map +1 -1
  6. package/dist/heartbeat.d.ts.map +1 -1
  7. package/dist/index.js +16 -3
  8. package/dist/index.js.map +1 -1
  9. package/dist/latency-probes.d.ts +18 -0
  10. package/dist/latency-probes.d.ts.map +1 -0
  11. package/dist/runtime-env.d.ts +6 -0
  12. package/dist/runtime-env.d.ts.map +1 -1
  13. package/dist/server.d.ts +16 -0
  14. package/dist/server.d.ts.map +1 -1
  15. package/dist/server.js +1 -1
  16. package/dist/voice/asr-ws.d.ts +8 -0
  17. package/dist/voice/asr-ws.d.ts.map +1 -0
  18. package/dist/voice/bailian-asr.d.ts +35 -0
  19. package/dist/voice/bailian-asr.d.ts.map +1 -0
  20. package/dist/voice/bailian-endpoints.d.ts +4 -0
  21. package/dist/voice/bailian-endpoints.d.ts.map +1 -0
  22. package/dist/voice/bailian-provider.d.ts +13 -0
  23. package/dist/voice/bailian-provider.d.ts.map +1 -0
  24. package/dist/voice/bailian-tts.d.ts +33 -0
  25. package/dist/voice/bailian-tts.d.ts.map +1 -0
  26. package/dist/voice/capabilities.d.ts +21 -0
  27. package/dist/voice/capabilities.d.ts.map +1 -0
  28. package/dist/voice/client-controls.d.ts +7 -0
  29. package/dist/voice/client-controls.d.ts.map +1 -0
  30. package/dist/voice/config-store.d.ts +22 -0
  31. package/dist/voice/config-store.d.ts.map +1 -0
  32. package/dist/voice/config-test.d.ts +22 -0
  33. package/dist/voice/config-test.d.ts.map +1 -0
  34. package/dist/voice/provider.d.ts +41 -0
  35. package/dist/voice/provider.d.ts.map +1 -0
  36. package/dist/voice/tts-ws.d.ts +8 -0
  37. package/dist/voice/tts-ws.d.ts.map +1 -0
  38. package/package.json +2 -2
  39. package/dist/chunk-DFVUNUQH.js +0 -1705
  40. package/dist/chunk-DFVUNUQH.js.map +0 -1
@@ -0,0 +1,3359 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/server.ts
4
+ import express from "express";
5
+ import { existsSync as existsSync2 } from "fs";
6
+ import { createServer } from "http";
7
+ import { homedir } from "os";
8
+ import { dirname as dirname3, resolve } from "path";
9
+ import { fileURLToPath as fileURLToPath2 } from "url";
10
+ import { WebSocketServer } from "ws";
11
+
12
+ // src/registry.ts
13
+ import { WebSocket } from "ws";
14
+
15
+ // ../../packages/shared/dist/schemas/envelope.js
16
+ import { z as z6 } from "zod";
17
+
18
+ // ../../packages/shared/dist/schemas/id.js
19
+ import { z } from "zod";
20
+ var MAX_ID_LENGTH = 256;
21
+ var IdSchema = z.string().min(1).max(MAX_ID_LENGTH);
22
+
23
+ // ../../packages/shared/dist/schemas/chat.js
24
+ import { z as z2 } from "zod";
25
+ var UserInputPayloadSchema = z2.object({
26
+ text: z2.string().min(1),
27
+ messageId: z2.string().min(1).optional()
28
+ });
29
+ var AssistantMessagePayloadSchema = z2.object({
30
+ text: z2.string(),
31
+ isPartial: z2.boolean()
32
+ });
33
+ var ThinkingPayloadSchema = z2.object({
34
+ text: z2.string()
35
+ });
36
+
37
+ // ../../packages/shared/dist/schemas/tool.js
38
+ import { z as z3 } from "zod";
39
+ var ToolUseRequestPayloadSchema = z3.object({
40
+ toolName: z3.string(),
41
+ toolId: IdSchema,
42
+ parameters: z3.record(z3.string(), z3.unknown())
43
+ });
44
+ var ToolApprovePayloadSchema = z3.object({
45
+ toolId: IdSchema,
46
+ whitelistTool: z3.boolean().optional()
47
+ });
48
+ var ToolDenyPayloadSchema = z3.object({
49
+ toolId: IdSchema,
50
+ reason: z3.string().optional()
51
+ });
52
+ var ToolResultPayloadSchema = z3.object({
53
+ toolId: IdSchema,
54
+ result: z3.unknown(),
55
+ isError: z3.boolean()
56
+ });
57
+
58
+ // ../../packages/shared/dist/schemas/session.js
59
+ import { z as z4 } from "zod";
60
+
61
+ // ../../packages/shared/dist/constants/enums.js
62
+ var providerValues = ["claude", "codex"];
63
+ var ptyOwnerValues = ["local-terminal", "proxy-hosted"];
64
+ var sessionModeValues = ["pty", "json"];
65
+
66
+ // ../../packages/shared/dist/constants/pty.js
67
+ var PtySemanticState = {
68
+ WORKING: "working",
69
+ TURN_COMPLETE: "turn_complete",
70
+ APPROVAL_WAIT: "approval_wait"
71
+ };
72
+ var ptySemanticStateValues = [
73
+ PtySemanticState.WORKING,
74
+ PtySemanticState.TURN_COMPLETE,
75
+ PtySemanticState.APPROVAL_WAIT
76
+ ];
77
+
78
+ // ../../packages/shared/dist/schemas/session.js
79
+ var sessionStateValues = [
80
+ "idle",
81
+ "working",
82
+ "waiting_approval",
83
+ "error",
84
+ "terminated"
85
+ ];
86
+ var agentStatusPhaseValues = [
87
+ "idle",
88
+ "thinking",
89
+ "tool_use",
90
+ "outputting",
91
+ "waiting_permission",
92
+ "error"
93
+ ];
94
+ var SessionInfoSchema = z4.object({
95
+ sessionId: IdSchema,
96
+ name: z4.string().optional(),
97
+ // cwd 只用于展示完整路径/tooltip,不作为前端路由或权限判断来源。
98
+ cwd: z4.string().optional(),
99
+ // true 表示 name 是用户显式命名,PTY UI 不再让 OSC terminal_title 覆盖它。
100
+ nameLocked: z4.boolean().optional(),
101
+ state: z4.enum(sessionStateValues),
102
+ mode: z4.enum(sessionModeValues).optional(),
103
+ provider: z4.enum(providerValues),
104
+ // PTY 尺寸所有权:
105
+ // - local-terminal: 本地 terminal 进程持有真实 PTY,Web 只按原始 cols/rows 展示
106
+ // - proxy-hosted: serve 内托管 PTY,Web 可按视口请求 resize
107
+ ptyOwner: z4.enum(ptyOwnerValues).optional(),
108
+ lastActive: z4.number().optional()
109
+ });
110
+ var SessionCreatePayloadSchema = z4.object({
111
+ name: z4.string().optional(),
112
+ cwd: z4.string().optional(),
113
+ streamDelta: z4.boolean().optional()
114
+ });
115
+ var SessionListPayloadSchema = z4.object({
116
+ sessions: z4.array(SessionInfoSchema)
117
+ });
118
+ var SessionSwitchPayloadSchema = z4.object({
119
+ sessionId: IdSchema
120
+ });
121
+ var SessionTerminatePayloadSchema = z4.object({
122
+ sessionId: IdSchema
123
+ });
124
+ var SessionStatusPayloadSchema = z4.object({
125
+ sessionId: IdSchema,
126
+ state: z4.enum(sessionStateValues),
127
+ lastActive: z4.number()
128
+ });
129
+ var PtyStatePayloadSchema = z4.object({
130
+ state: z4.enum(ptySemanticStateValues),
131
+ title: z4.string().optional(),
132
+ tool: z4.string().optional()
133
+ });
134
+ var AgentStatusPayloadSchema = z4.object({
135
+ provider: z4.enum(providerValues),
136
+ phase: z4.enum(agentStatusPhaseValues),
137
+ seq: z4.number().int().nonnegative(),
138
+ updatedAt: z4.number(),
139
+ toolName: z4.string().optional(),
140
+ toolInput: z4.record(z4.string(), z4.unknown()).optional(),
141
+ permissionRequest: z4.object({
142
+ requestId: IdSchema,
143
+ toolName: z4.string(),
144
+ input: z4.record(z4.string(), z4.unknown())
145
+ }).optional(),
146
+ permissionResolution: z4.object({
147
+ requestId: IdSchema,
148
+ outcome: z4.enum(["allow", "deny"])
149
+ }).optional(),
150
+ summary: z4.string().optional()
151
+ });
152
+
153
+ // ../../packages/shared/dist/schemas/system.js
154
+ import { z as z5 } from "zod";
155
+ var HeartbeatPayloadSchema = z5.object({});
156
+ var AuthPayloadSchema = z5.object({
157
+ pairingCode: z5.string().optional(),
158
+ token: z5.string().optional()
159
+ });
160
+ var SyncRequestPayloadSchema = z5.object({
161
+ lastSeq: z5.number().int().nonnegative()
162
+ });
163
+ var SyncResponsePayloadSchema = z5.object({
164
+ messages: z5.array(z5.record(z5.string(), z5.unknown()))
165
+ });
166
+
167
+ // ../../packages/shared/dist/schemas/envelope.js
168
+ var BaseEnvelopeFields = {
169
+ seq: z6.number().int().nonnegative(),
170
+ timestamp: z6.number(),
171
+ source: z6.enum(["proxy", "client"]),
172
+ version: z6.string()
173
+ };
174
+ var SessionedEnvelopeFields = {
175
+ ...BaseEnvelopeFields,
176
+ sessionId: IdSchema
177
+ };
178
+ var MessageEnvelopeSchema = z6.discriminatedUnion("type", [
179
+ // chat (3)
180
+ z6.object({
181
+ ...SessionedEnvelopeFields,
182
+ type: z6.literal("user_input"),
183
+ payload: UserInputPayloadSchema
184
+ }),
185
+ z6.object({
186
+ ...SessionedEnvelopeFields,
187
+ type: z6.literal("assistant_message"),
188
+ payload: AssistantMessagePayloadSchema
189
+ }),
190
+ z6.object({
191
+ ...SessionedEnvelopeFields,
192
+ type: z6.literal("thinking"),
193
+ payload: ThinkingPayloadSchema
194
+ }),
195
+ // tool (4): 工具审批决策属于 relay control,不进入会话消息信封。
196
+ // tool_use_request: 审批流请求(proxy → client),toolId 是 approval requestId
197
+ z6.object({
198
+ ...SessionedEnvelopeFields,
199
+ type: z6.literal("tool_use_request"),
200
+ payload: ToolUseRequestPayloadSchema
201
+ }),
202
+ // tool_result: 工具执行结果(proxy → client),toolId 对应 assistant_tool_use / tool_use_request 的 toolId
203
+ z6.object({
204
+ ...SessionedEnvelopeFields,
205
+ type: z6.literal("tool_result"),
206
+ payload: ToolResultPayloadSchema
207
+ }),
208
+ // assistant_tool_use: 纯展示型工具调用(proxy → client),区别于 tool_use_request 无审批语义
209
+ // payload 结构复用 ToolUseRequestPayloadSchema;toolId 是 Claude 分配的 tool_use id
210
+ z6.object({
211
+ ...SessionedEnvelopeFields,
212
+ type: z6.literal("assistant_tool_use"),
213
+ payload: ToolUseRequestPayloadSchema
214
+ }),
215
+ // session (5)
216
+ z6.object({
217
+ ...SessionedEnvelopeFields,
218
+ type: z6.literal("session_create"),
219
+ payload: SessionCreatePayloadSchema
220
+ }),
221
+ // session_list 是全局广播 (列出所有 session), 不绑定具体 sessionId, 不携带该字段。
222
+ z6.object({
223
+ ...BaseEnvelopeFields,
224
+ type: z6.literal("session_list"),
225
+ payload: SessionListPayloadSchema
226
+ }),
227
+ z6.object({
228
+ ...SessionedEnvelopeFields,
229
+ type: z6.literal("session_switch"),
230
+ payload: SessionSwitchPayloadSchema
231
+ }),
232
+ z6.object({
233
+ ...SessionedEnvelopeFields,
234
+ type: z6.literal("session_terminate"),
235
+ payload: SessionTerminatePayloadSchema
236
+ }),
237
+ z6.object({
238
+ ...SessionedEnvelopeFields,
239
+ type: z6.literal("session_status"),
240
+ payload: SessionStatusPayloadSchema
241
+ }),
242
+ // system (5): 心跳 / 认证 / 同步——全局, 无 sessionId
243
+ z6.object({
244
+ ...BaseEnvelopeFields,
245
+ type: z6.literal("heartbeat"),
246
+ payload: HeartbeatPayloadSchema
247
+ }),
248
+ z6.object({
249
+ ...BaseEnvelopeFields,
250
+ type: z6.literal("auth"),
251
+ payload: AuthPayloadSchema
252
+ }),
253
+ z6.object({
254
+ ...BaseEnvelopeFields,
255
+ type: z6.literal("sync_request"),
256
+ payload: SyncRequestPayloadSchema
257
+ }),
258
+ z6.object({
259
+ ...BaseEnvelopeFields,
260
+ type: z6.literal("sync_response"),
261
+ payload: SyncResponsePayloadSchema
262
+ })
263
+ ]);
264
+
265
+ // ../../packages/shared/dist/schemas/voice.js
266
+ import { z as z7 } from "zod";
267
+ var voiceProviderValues = ["aliyun-bailian"];
268
+ var voiceRegionValues = ["cn", "intl"];
269
+ var voiceOptionSourceValues = ["official", "custom"];
270
+ var voiceOptionGenderValues = ["male", "female", "unknown"];
271
+ var VoiceProviderConfigSchema = z7.object({
272
+ provider: z7.enum(voiceProviderValues),
273
+ configured: z7.boolean(),
274
+ region: z7.enum(voiceRegionValues),
275
+ asrModel: z7.string().min(1),
276
+ ttsModel: z7.string().min(1),
277
+ ttsVoice: z7.string().min(1),
278
+ turnIdleSeconds: z7.number().int().positive().safe().default(3)
279
+ }).strict();
280
+ var VoiceConfigUpdateSchema = z7.object({
281
+ provider: z7.enum(voiceProviderValues).optional(),
282
+ apiKey: z7.string().min(1).optional(),
283
+ clearApiKey: z7.boolean().optional(),
284
+ region: z7.enum(voiceRegionValues).optional(),
285
+ asrModel: z7.string().min(1).optional(),
286
+ ttsModel: z7.string().min(1).optional(),
287
+ ttsVoice: z7.string().min(1).optional(),
288
+ turnIdleSeconds: z7.number().int().positive().safe().optional()
289
+ }).strict();
290
+ var VoiceOptionSchema = z7.object({
291
+ value: z7.string().min(1),
292
+ label: z7.string().min(1),
293
+ description: z7.string().min(1).optional(),
294
+ gender: z7.enum(voiceOptionGenderValues).optional(),
295
+ age: z7.string().min(1).optional(),
296
+ model: z7.string().min(1).optional(),
297
+ source: z7.enum(voiceOptionSourceValues)
298
+ }).strict();
299
+ var VoiceCapabilitiesSchema = z7.object({
300
+ asrModels: z7.array(VoiceOptionSchema),
301
+ ttsModels: z7.array(VoiceOptionSchema),
302
+ ttsVoices: z7.array(VoiceOptionSchema),
303
+ fetchedAt: z7.number().optional()
304
+ }).strict();
305
+ var BUNDLED_BAILIAN_ASR_MODELS = [
306
+ {
307
+ value: "qwen3-asr-flash-realtime",
308
+ label: "Qwen3 ASR Flash Realtime",
309
+ source: "official"
310
+ },
311
+ {
312
+ value: "qwen3-asr-flash-realtime-2026-02-10",
313
+ label: "Qwen3 ASR Flash Realtime \xB7 2026-02-10",
314
+ source: "official"
315
+ },
316
+ {
317
+ value: "qwen3-asr-flash-realtime-2025-10-27",
318
+ label: "Qwen3 ASR Flash Realtime \xB7 2025-10-27",
319
+ source: "official"
320
+ }
321
+ ];
322
+ var BUNDLED_BAILIAN_TTS_MODELS = [
323
+ {
324
+ value: "cosyvoice-v3-flash",
325
+ label: "CosyVoice V3 Flash \xB7 \u7CFB\u7EDF\u97F3\u8272",
326
+ source: "official"
327
+ },
328
+ {
329
+ value: "cosyvoice-v3-plus",
330
+ label: "CosyVoice V3 Plus \xB7 \u7CFB\u7EDF\u97F3\u8272",
331
+ source: "official"
332
+ },
333
+ {
334
+ value: "cosyvoice-v3.5-flash",
335
+ label: "CosyVoice V3.5 Flash \xB7 \u81EA\u5B9A\u4E49\u97F3\u8272",
336
+ source: "official"
337
+ },
338
+ {
339
+ value: "cosyvoice-v3.5-plus",
340
+ label: "CosyVoice V3.5 Plus \xB7 \u81EA\u5B9A\u4E49\u97F3\u8272",
341
+ source: "official"
342
+ }
343
+ ];
344
+ var BUNDLED_BAILIAN_TTS_VOICES = [
345
+ {
346
+ value: "longanyang",
347
+ label: "\u9F99\u5B89\u6D0B \xB7 \u7537 \xB7 \u9633\u5149\u5927\u7537\u5B69 \xB7 \u5E74\u9F84 20-30",
348
+ gender: "male",
349
+ age: "20-30",
350
+ model: "cosyvoice-v3-flash",
351
+ source: "official"
352
+ },
353
+ {
354
+ value: "longanhuan",
355
+ label: "\u9F99\u5B89\u6B22 \xB7 \u5973 \xB7 \u6B22\u8131\u5143\u6C14 \xB7 \u5E74\u9F84 20-30",
356
+ gender: "female",
357
+ age: "20-30",
358
+ model: "cosyvoice-v3-flash",
359
+ source: "official"
360
+ },
361
+ {
362
+ value: "longhuhu_v3",
363
+ label: "\u9F99\u547C\u547C \xB7 \u5973 \xB7 \u5929\u771F\u70C2\u6F2B\u5973\u7AE5 \xB7 \u5E74\u9F84 6-10",
364
+ gender: "female",
365
+ age: "6-10",
366
+ model: "cosyvoice-v3-flash",
367
+ source: "official"
368
+ },
369
+ {
370
+ value: "longpaopao_v3",
371
+ label: "\u9F99\u6CE1\u6CE1 \xB7 \u672A\u77E5 \xB7 \u98DE\u5929\u6CE1\u6CE1\u97F3 \xB7 \u5E74\u9F84 6-15",
372
+ gender: "unknown",
373
+ age: "6-15",
374
+ model: "cosyvoice-v3-flash",
375
+ source: "official"
376
+ },
377
+ {
378
+ value: "longjielidou_v3",
379
+ label: "\u9F99\u6770\u529B\u8C46 \xB7 \u7537 \xB7 \u9633\u5149\u987D\u76AE \xB7 \u5E74\u9F84 10",
380
+ gender: "male",
381
+ age: "10",
382
+ model: "cosyvoice-v3-flash",
383
+ source: "official"
384
+ },
385
+ {
386
+ value: "longxian_v3",
387
+ label: "\u9F99\u4ED9 \xB7 \u5973 \xB7 \u8C6A\u653E\u53EF\u7231 \xB7 \u5E74\u9F84 12",
388
+ gender: "female",
389
+ age: "12",
390
+ model: "cosyvoice-v3-flash",
391
+ source: "official"
392
+ },
393
+ {
394
+ value: "longling_v3",
395
+ label: "\u9F99\u94C3 \xB7 \u5973 \xB7 \u7A1A\u6C14\u5446\u677F \xB7 \u5E74\u9F84 10",
396
+ gender: "female",
397
+ age: "10",
398
+ model: "cosyvoice-v3-flash",
399
+ source: "official"
400
+ },
401
+ {
402
+ value: "longjiaxin_v3",
403
+ label: "\u9F99\u5609\u6B23 \xB7 \u5973 \xB7 \u4F18\u96C5\u7CA4\u8BED \xB7 \u5E74\u9F84 30-35",
404
+ gender: "female",
405
+ age: "30-35",
406
+ model: "cosyvoice-v3-flash",
407
+ source: "official"
408
+ },
409
+ {
410
+ value: "longanyue_v3",
411
+ label: "\u9F99\u5B89\u7CA4 \xB7 \u7537 \xB7 \u6B22\u8131\u7CA4\u8BED \xB7 \u5E74\u9F84 25-35",
412
+ gender: "male",
413
+ age: "25-35",
414
+ model: "cosyvoice-v3-flash",
415
+ source: "official"
416
+ },
417
+ {
418
+ value: "longlaotie_v3",
419
+ label: "\u9F99\u8001\u94C1 \xB7 \u7537 \xB7 \u4E1C\u5317\u76F4\u7387 \xB7 \u5E74\u9F84 25-30",
420
+ gender: "male",
421
+ age: "25-30",
422
+ model: "cosyvoice-v3-flash",
423
+ source: "official"
424
+ },
425
+ {
426
+ value: "longanyang",
427
+ label: "\u9F99\u5B89\u6D0B \xB7 \u7537 \xB7 \u9633\u5149\u5927\u7537\u5B69 \xB7 \u5E74\u9F84 20-30",
428
+ gender: "male",
429
+ age: "20-30",
430
+ model: "cosyvoice-v3-plus",
431
+ source: "official"
432
+ },
433
+ {
434
+ value: "longanhuan",
435
+ label: "\u9F99\u5B89\u6B22 \xB7 \u5973 \xB7 \u6B22\u8131\u5143\u6C14 \xB7 \u5E74\u9F84 20-30",
436
+ gender: "female",
437
+ age: "20-30",
438
+ model: "cosyvoice-v3-plus",
439
+ source: "official"
440
+ }
441
+ ];
442
+ function cloneVoiceOption(option) {
443
+ return { ...option };
444
+ }
445
+ function createBundledBailianVoiceCapabilities(fetchedAt) {
446
+ return {
447
+ asrModels: BUNDLED_BAILIAN_ASR_MODELS.map(cloneVoiceOption),
448
+ ttsModels: BUNDLED_BAILIAN_TTS_MODELS.map(cloneVoiceOption),
449
+ ttsVoices: BUNDLED_BAILIAN_TTS_VOICES.map(cloneVoiceOption),
450
+ ...typeof fetchedAt === "number" ? { fetchedAt } : {}
451
+ };
452
+ }
453
+ var VoiceSummaryReasonSchema = z7.enum([
454
+ "code",
455
+ "table",
456
+ "diff",
457
+ "log",
458
+ "stack_trace",
459
+ "long_list",
460
+ "long_text",
461
+ "mixed",
462
+ "approval"
463
+ ]);
464
+
465
+ // ../../packages/shared/dist/builders/index.js
466
+ function serializeControl(msg) {
467
+ return JSON.stringify(msg);
468
+ }
469
+
470
+ // ../../packages/shared/dist/schemas/relay-control.js
471
+ import { z as z8 } from "zod";
472
+
473
+ // ../../packages/shared/dist/constants/relay-errors.js
474
+ var RelayErrorCode = {
475
+ NOT_REGISTERED: "NOT_REGISTERED",
476
+ NOT_BOUND: "NOT_BOUND",
477
+ PROXY_OFFLINE: "PROXY_OFFLINE",
478
+ INVALID_MESSAGE: "INVALID_MESSAGE",
479
+ UNSUPPORTED: "UNSUPPORTED",
480
+ INVALID_RANGE: "INVALID_RANGE"
481
+ };
482
+
483
+ // ../../packages/shared/dist/constants/control-errors.js
484
+ var ControlErrorCode = {
485
+ INVALID_PATH: "INVALID_PATH",
486
+ PATH_NOT_FOUND: "PATH_NOT_FOUND",
487
+ PATH_NOT_DIRECTORY: "PATH_NOT_DIRECTORY",
488
+ PATH_ACCESS_DENIED: "PATH_ACCESS_DENIED",
489
+ PROXY_OFFLINE: "PROXY_OFFLINE",
490
+ SESSION_NOT_FOUND: "SESSION_NOT_FOUND",
491
+ PROVIDER_UNSUPPORTED: "PROVIDER_UNSUPPORTED",
492
+ WORKER_START_FAILED: "WORKER_START_FAILED",
493
+ PROCESS_START_FAILED: "PROCESS_START_FAILED",
494
+ UNKNOWN: "UNKNOWN"
495
+ };
496
+
497
+ // ../../packages/shared/dist/schemas/relay-control.js
498
+ var ProxyInfoSchema = z8.object({
499
+ proxyId: IdSchema,
500
+ name: z8.string().optional(),
501
+ online: z8.boolean(),
502
+ sessions: z8.array(z8.string()).optional()
503
+ });
504
+ var AgentCliAvailabilitySchema = z8.object({
505
+ available: z8.boolean(),
506
+ command: z8.string().optional(),
507
+ error: z8.string().optional(),
508
+ suggestions: z8.array(z8.string()).optional()
509
+ });
510
+ var AgentCliStatusSchema = z8.object({
511
+ claude: AgentCliAvailabilitySchema,
512
+ codex: AgentCliAvailabilitySchema
513
+ });
514
+ var DirEntrySchema = z8.object({ name: z8.string(), isDir: z8.boolean() });
515
+ var FileTreeGroupSchema = z8.object({
516
+ path: z8.string(),
517
+ entries: z8.array(DirEntrySchema)
518
+ });
519
+ var CommandEntrySchema = z8.object({
520
+ name: z8.string(),
521
+ description: z8.string(),
522
+ argumentHint: z8.string().optional(),
523
+ source: z8.string()
524
+ });
525
+ var HistorySessionSchema = z8.object({
526
+ id: z8.string(),
527
+ title: z8.string(),
528
+ projectDir: z8.string(),
529
+ updatedAt: z8.number(),
530
+ provider: z8.enum(providerValues).optional()
531
+ });
532
+ var SessionHistoryMessageSchema = z8.object({
533
+ role: z8.enum(["user", "assistant"]),
534
+ text: z8.string(),
535
+ timestamp: z8.number().optional(),
536
+ cursor: z8.string().optional()
537
+ });
538
+ var RequestIdShape = { requestId: IdSchema.optional() };
539
+ var RequiredRequestIdShape = { requestId: IdSchema };
540
+ var ControlErrorCodeSchema = z8.enum(Object.values(ControlErrorCode));
541
+ var RequestErrorShape = {
542
+ error: z8.string().optional(),
543
+ errorCode: ControlErrorCodeSchema.optional()
544
+ };
545
+ var ClipboardImageMimeTypeSchema = z8.enum(["image/png", "image/jpeg", "image/webp", "image/gif"]);
546
+ function control(type, shape, directions) {
547
+ return {
548
+ type,
549
+ directions: new Set(Array.isArray(directions) ? directions : directions ? [directions] : []),
550
+ schema: z8.object({
551
+ type: z8.literal(type),
552
+ ...shape ?? {}
553
+ })
554
+ };
555
+ }
556
+ var relayControlDefinitions = [
557
+ control("proxy_register", {
558
+ proxyId: IdSchema,
559
+ name: z8.string().optional()
560
+ }),
561
+ control("proxy_register_response", {
562
+ status: z8.enum(["new", "reconnected"])
563
+ }),
564
+ control("proxy_list_request", RequestIdShape),
565
+ control("proxy_list_response", {
566
+ ...RequestIdShape,
567
+ proxies: z8.array(ProxyInfoSchema)
568
+ }),
569
+ control("proxy_select", { ...RequestIdShape, proxyId: IdSchema }),
570
+ control("proxy_select_response", {
571
+ ...RequestIdShape,
572
+ success: z8.boolean(),
573
+ proxyId: IdSchema.optional(),
574
+ ...RequestErrorShape
575
+ }),
576
+ control("relay_error", {
577
+ code: z8.enum(Object.values(RelayErrorCode)),
578
+ message: z8.string(),
579
+ // 可选 requestId: relay 把 client 发来 raw 的 requestId 字段透传回来,
580
+ // client 侧 waitForMessage 据此把对应 pending request 立即拒掉而不必等到 timeout。
581
+ requestId: IdSchema.optional()
582
+ }),
583
+ // Voice Pilot config is relay-local: client reads/updates the relay's stored provider settings.
584
+ control("voice_config_request", RequestIdShape),
585
+ control("voice_config_response", {
586
+ ...RequestIdShape,
587
+ ...RequestErrorShape,
588
+ config: VoiceProviderConfigSchema.optional()
589
+ }),
590
+ control("voice_config_update", {
591
+ ...RequestIdShape,
592
+ config: VoiceConfigUpdateSchema
593
+ }),
594
+ control("voice_config_update_response", {
595
+ ...RequestIdShape,
596
+ ...RequestErrorShape,
597
+ success: z8.boolean(),
598
+ config: VoiceProviderConfigSchema.optional()
599
+ }),
600
+ control("voice_config_test", {
601
+ ...RequestIdShape,
602
+ config: VoiceConfigUpdateSchema.optional()
603
+ }),
604
+ control("voice_config_test_response", {
605
+ ...RequestIdShape,
606
+ ...RequestErrorShape,
607
+ success: z8.boolean(),
608
+ audioBase64: z8.string().optional(),
609
+ audioSampleRate: z8.number().int().positive().optional(),
610
+ audioEncoding: z8.literal("pcm_s16le").optional(),
611
+ transcript: z8.string().optional()
612
+ }),
613
+ control("voice_capabilities_request", {
614
+ ...RequestIdShape,
615
+ region: z8.enum(voiceRegionValues).optional()
616
+ }),
617
+ control("voice_capabilities_response", {
618
+ ...RequestIdShape,
619
+ ...RequestErrorShape,
620
+ capabilities: VoiceCapabilitiesSchema.optional()
621
+ }),
622
+ // Lightweight latency probes. These measure synthetic round-trip latency for the transport
623
+ // segments and intentionally stay separate from PTY input echo tracing.
624
+ control("latency_web_relay_ping", RequiredRequestIdShape),
625
+ control("latency_web_relay_pong", {
626
+ ...RequiredRequestIdShape,
627
+ relayNow: z8.number().optional()
628
+ }),
629
+ control("latency_relay_proxy_request", RequiredRequestIdShape),
630
+ control("latency_relay_proxy_response", {
631
+ ...RequiredRequestIdShape,
632
+ success: z8.boolean(),
633
+ rttMs: z8.number().nonnegative().optional(),
634
+ error: z8.string().optional()
635
+ }),
636
+ control("latency_relay_proxy_ping", {
637
+ ...RequiredRequestIdShape,
638
+ relayNow: z8.number().optional()
639
+ }),
640
+ control("latency_relay_proxy_pong", {
641
+ ...RequiredRequestIdShape,
642
+ proxyNow: z8.number().optional()
643
+ }),
644
+ control("latency_web_proxy_ping", RequiredRequestIdShape, "client_to_proxy"),
645
+ control("latency_web_proxy_pong", { ...RequiredRequestIdShape, proxyNow: z8.number().optional() }, "proxy_to_client"),
646
+ // 客户端注册协议
647
+ control("client_register", {
648
+ clientId: IdSchema
649
+ }),
650
+ control("client_register_response", {
651
+ status: z8.enum(["restored", "proxy_offline", "new"]),
652
+ proxyId: IdSchema.optional()
653
+ }),
654
+ // Proxy 离线通知
655
+ control("proxy_offline", {
656
+ proxyId: IdSchema
657
+ }),
658
+ // Proxy 主动断开,relay 立即清理资源
659
+ control("proxy_disconnect", {
660
+ proxyId: IdSchema
661
+ }),
662
+ // Proxy 重连后通知 client 恢复
663
+ control("proxy_online", {
664
+ proxyId: IdSchema
665
+ }),
666
+ // 目录列表请求与响应
667
+ control("dir_list_request", {
668
+ proxyId: IdSchema.optional(),
669
+ ...RequestIdShape,
670
+ path: z8.string()
671
+ }, "client_to_proxy"),
672
+ control("dir_list_response", { ...RequestIdShape, ...RequestErrorShape, entries: z8.array(DirEntrySchema), path: z8.string() }, "proxy_to_client"),
673
+ // 目录创建请求与响应
674
+ control("dir_create_request", { ...RequestIdShape, path: z8.string() }, "client_to_proxy"),
675
+ control("dir_create_response", {
676
+ ...RequestIdShape,
677
+ ...RequestErrorShape,
678
+ path: z8.string(),
679
+ success: z8.boolean()
680
+ }, "proxy_to_client"),
681
+ // 命令列表推送,proxy 将可用命令列表推给 client
682
+ control("command_list_push", { commands: z8.array(CommandEntrySchema) }, "proxy_to_client"),
683
+ // 文件树推送: 按目录分组, 首组 path 即为 session cwd
684
+ // 前端直接把每组写入 tree[path], 与 dir_list_response 共享 cache slot
685
+ control("file_tree_push", {
686
+ groups: z8.array(FileTreeGroupSchema)
687
+ }, "proxy_to_client"),
688
+ // 会话列表请求与权限模式变更
689
+ control("session_list", void 0, ["client_to_proxy", "proxy_to_client"]),
690
+ control("permission_mode_change", {
691
+ mode: z8.enum(["default", "auto_accept", "plan"]),
692
+ // sessionId 可选:传入时 proxy 按该会话的 mode 分叉(PTY 发 Tab ANSI),未传走全局日志行为
693
+ sessionId: IdSchema.optional()
694
+ }, "client_to_proxy"),
695
+ // 会话历史浏览
696
+ control("session_history_request", RequestIdShape, "client_to_proxy"),
697
+ control("session_history_response", { ...RequestIdShape, sessions: z8.array(HistorySessionSchema) }, "proxy_to_client"),
698
+ // PTY 语义状态,从 Envelope 迁移到 Control 层
699
+ control("pty_state", { sessionId: IdSchema, payload: PtyStatePayloadSchema }, "proxy_to_client"),
700
+ // Provider 语义状态,来自 Claude/Codex hook 等结构化事件,不从 PTY 字节推断
701
+ control("agent_status", { sessionId: IdSchema, payload: AgentStatusPayloadSchema }, "proxy_to_client"),
702
+ // 终端标题变化,proxy -> client
703
+ control("terminal_title", { sessionId: IdSchema, title: z8.string() }, "proxy_to_client"),
704
+ // 终端尺寸变化,proxy -> client
705
+ control("terminal_resize", { sessionId: IdSchema, cols: z8.number().int().positive(), rows: z8.number().int().positive() }, "proxy_to_client"),
706
+ control("terminal_resize_request", { sessionId: IdSchema, cols: z8.number().int().positive(), rows: z8.number().int().positive() }, "client_to_proxy"),
707
+ // 远程终止 JSON 会话,client -> proxy
708
+ control("session_terminate", { sessionId: IdSchema }, "client_to_proxy"),
709
+ control("session_rename", { ...RequestIdShape, sessionId: IdSchema, name: z8.string() }, "client_to_proxy"),
710
+ control("session_rename_response", {
711
+ ...RequestIdShape,
712
+ sessionId: IdSchema,
713
+ success: z8.boolean(),
714
+ name: z8.string().optional(),
715
+ ...RequestErrorShape
716
+ }, "proxy_to_client"),
717
+ // 中断当前 turn,client -> proxy,SIGINT 到 worker 进程让 claude CLI abort 当前流
718
+ control("session_worker_abort", { sessionId: IdSchema }, "client_to_proxy"),
719
+ // turn 完成信号,proxy -> client,对应 claude stream-json 的 result 事件
720
+ control("turn_result", {
721
+ sessionId: IdSchema,
722
+ success: z8.boolean(),
723
+ isError: z8.boolean(),
724
+ // stream-json result.result 是本轮最终文本。assistant_message 流丢失或 CLI 未发增量时,
725
+ // Web 用它作为 JSON 模式兜底展示,避免 turn 已结束但界面空白。
726
+ result: z8.string().optional()
727
+ }, "proxy_to_client"),
728
+ // 客户端发送到 PTY 的原始字节(ANSI 序列),不追加换行
729
+ control("remote_input_raw", { sessionId: IdSchema, data: z8.string(), traceId: IdSchema.optional() }, "client_to_proxy"),
730
+ control("clipboard_image_upload", {
731
+ ...RequestIdShape,
732
+ sessionId: IdSchema,
733
+ mimeType: ClipboardImageMimeTypeSchema,
734
+ dataBase64: z8.string().min(1),
735
+ fileName: z8.string().optional()
736
+ }, "client_to_proxy"),
737
+ control("clipboard_image_upload_response", {
738
+ ...RequestIdShape,
739
+ ...RequestErrorShape,
740
+ sessionId: IdSchema,
741
+ success: z8.boolean(),
742
+ // success=false 时 proxy 没有有效 path 可填;保持 optional 以避免占位空字符串通过校验。
743
+ path: z8.string().optional()
744
+ }, "proxy_to_client"),
745
+ control("image_preview_request", {
746
+ ...RequestIdShape,
747
+ sessionId: IdSchema,
748
+ path: z8.string().min(1)
749
+ }, "client_to_proxy"),
750
+ control("image_preview_response", {
751
+ ...RequestIdShape,
752
+ ...RequestErrorShape,
753
+ sessionId: IdSchema,
754
+ success: z8.boolean(),
755
+ // 同 clipboard_image_upload_response:失败时 proxy 不一定有路径。
756
+ path: z8.string().optional(),
757
+ mimeType: ClipboardImageMimeTypeSchema.optional(),
758
+ dataBase64: z8.string().optional(),
759
+ size: z8.number().int().nonnegative().optional()
760
+ }, "proxy_to_client"),
761
+ // 任意文件下载: 与 image_preview 形状对称, 只是 mimeType 不限定为图片;
762
+ // 单租户场景下 path 任意 (不受 previewRoots 限制), 由 proxy 端 size cap 兜底。
763
+ control("file_download_request", {
764
+ ...RequestIdShape,
765
+ sessionId: IdSchema,
766
+ path: z8.string().min(1)
767
+ }, "client_to_proxy"),
768
+ control("file_download_response", {
769
+ ...RequestIdShape,
770
+ ...RequestErrorShape,
771
+ sessionId: IdSchema,
772
+ success: z8.boolean(),
773
+ path: z8.string().optional(),
774
+ mimeType: z8.string().optional(),
775
+ dataBase64: z8.string().optional(),
776
+ size: z8.number().int().nonnegative().optional()
777
+ }, "proxy_to_client"),
778
+ // 任意文件上传: 复用 clipboard_image_upload 的形状, mimeType 放开 + fileName 必填,
779
+ // 由 proxy 端写入 session cwd 的 .dev-anywhere/uploads/ 子目录, 返回相对路径供 web 拼成 @path。
780
+ control("file_upload_request", {
781
+ ...RequestIdShape,
782
+ sessionId: IdSchema,
783
+ mimeType: z8.string().min(1),
784
+ dataBase64: z8.string().min(1),
785
+ fileName: z8.string().min(1)
786
+ }, "client_to_proxy"),
787
+ control("file_upload_response", {
788
+ ...RequestIdShape,
789
+ ...RequestErrorShape,
790
+ sessionId: IdSchema,
791
+ success: z8.boolean(),
792
+ path: z8.string().optional()
793
+ }, "proxy_to_client"),
794
+ // 客户端询问 proxy 的环境信息 (home 路径等), client -> proxy -> response
795
+ // FilePathPicker 用 homePath 作为 select 模式下的默认起点, 新建会话时打开即可浏览
796
+ control("proxy_info_request", RequestIdShape, "client_to_proxy"),
797
+ control("proxy_info", { ...RequestIdShape, homePath: z8.string(), agentCli: AgentCliStatusSchema }, "proxy_to_client"),
798
+ control("agent_cli_config_update", { ...RequestIdShape, provider: z8.enum(providerValues), path: z8.string().min(1) }, "client_to_proxy"),
799
+ control("agent_cli_config_update_response", {
800
+ ...RequestIdShape,
801
+ provider: z8.enum(providerValues),
802
+ agentCli: AgentCliStatusSchema.optional(),
803
+ ...RequestErrorShape
804
+ }, "proxy_to_client"),
805
+ // 远程创建 JSON 会话,client -> proxy -> response
806
+ control("session_create", {
807
+ ...RequestIdShape,
808
+ cwd: z8.string(),
809
+ name: z8.string().optional(),
810
+ provider: z8.enum(providerValues),
811
+ mode: z8.enum(sessionModeValues).optional(),
812
+ resumeSessionId: z8.string().optional(),
813
+ // 透传给 claude CLI 的 --permission-mode, undefined 时 proxy 兜底为 "default"
814
+ permissionMode: z8.enum(["default", "auto", "acceptEdits", "plan", "bypassPermissions", "dontAsk"]).optional()
815
+ }, "client_to_proxy"),
816
+ control("session_create_response", {
817
+ ...RequestIdShape,
818
+ // 失败路径只送 errorCode/error, sessionId 此时无语义。成功路径才有 id。
819
+ sessionId: IdSchema.optional(),
820
+ name: z8.string().optional(),
821
+ nameLocked: z8.boolean().optional(),
822
+ mode: z8.enum(sessionModeValues).optional(),
823
+ provider: z8.enum(providerValues).optional(),
824
+ ptyOwner: z8.enum(ptyOwnerValues).optional(),
825
+ ...RequestErrorShape
826
+ }, "proxy_to_client"),
827
+ // 客户端请求会话历史消息,client -> proxy
828
+ control("session_messages_request", {
829
+ ...RequestIdShape,
830
+ sessionId: IdSchema,
831
+ limit: z8.number().int().min(1).max(200).optional(),
832
+ before: z8.string().optional()
833
+ }, "client_to_proxy"),
834
+ // 客户端请求会话资源(命令列表 + 文件树),client -> proxy
835
+ control("session_resources_request", { ...RequestIdShape, sessionId: IdSchema }, "client_to_proxy"),
836
+ control("session_resources_response", {
837
+ ...RequestIdShape,
838
+ ...RequestErrorShape,
839
+ sessionId: IdSchema,
840
+ commands: z8.array(CommandEntrySchema),
841
+ groups: z8.array(FileTreeGroupSchema)
842
+ }, "proxy_to_client"),
843
+ // 客户端请求当前 provider 语义状态;不经 relay 缓存,由 proxy 返回当前值
844
+ control("agent_status_request", { ...RequestIdShape, sessionId: IdSchema.optional() }, "client_to_proxy"),
845
+ control("agent_status_response", {
846
+ ...RequestIdShape,
847
+ statuses: z8.array(z8.object({ sessionId: IdSchema, payload: AgentStatusPayloadSchema }))
848
+ }, "proxy_to_client"),
849
+ // 客户端确认已收到审批请求;proxy 只记录送达状态,不把它当成用户决策
850
+ control("permission_request_delivered", { sessionId: IdSchema, requestId: IdSchema }, "client_to_proxy"),
851
+ control("tool_approve", { sessionId: IdSchema, payload: ToolApprovePayloadSchema }, "client_to_proxy"),
852
+ control("tool_deny", { sessionId: IdSchema, payload: ToolDenyPayloadSchema }, "client_to_proxy"),
853
+ // proxy 确认用户决策已进入 provider/worker 路径;web 用它更新审批卡片状态
854
+ control("permission_decision_result", {
855
+ sessionId: IdSchema,
856
+ requestId: IdSchema,
857
+ outcome: z8.enum(["allow", "deny"]),
858
+ delivered: z8.boolean(),
859
+ message: z8.string().optional()
860
+ }, "proxy_to_client"),
861
+ // proxy 推送当前 pending 的工具审批列表,client 据此恢复审批卡片
862
+ control("pending_approvals_push", {
863
+ sessionId: IdSchema,
864
+ approvals: z8.array(z8.object({
865
+ requestId: IdSchema,
866
+ toolName: z8.string(),
867
+ input: z8.record(z8.string(), z8.unknown())
868
+ }))
869
+ }, "proxy_to_client"),
870
+ // Voice Pilot speech summaries are produced by proxy-side Claude Code so it can read project context.
871
+ control("voice_summary_request", {
872
+ ...RequestIdShape,
873
+ sessionId: IdSchema,
874
+ messageId: IdSchema,
875
+ text: z8.string().min(1),
876
+ reason: VoiceSummaryReasonSchema
877
+ }, "client_to_proxy"),
878
+ control("voice_summary_response", {
879
+ ...RequestIdShape,
880
+ ...RequestErrorShape,
881
+ sessionId: IdSchema,
882
+ messageId: IdSchema,
883
+ success: z8.boolean(),
884
+ summary: z8.string().min(1).optional()
885
+ }, "proxy_to_client"),
886
+ // 恢复会话时推送历史消息,proxy -> client
887
+ control("session_history_messages", {
888
+ ...RequestIdShape,
889
+ sessionId: IdSchema,
890
+ before: z8.string().optional(),
891
+ messages: z8.array(SessionHistoryMessageSchema),
892
+ hasMore: z8.boolean().optional(),
893
+ nextBefore: z8.string().optional()
894
+ }, "proxy_to_client"),
895
+ // proxy 重连后同步活跃 session 列表给 relay。session_sync 由 relay 自消费(更新 proxy-session
896
+ // 关联)不转发给 client,因此**没有** direction 标注——RelayControlDirection 只描述转发流。
897
+ control("session_sync", {
898
+ sessions: z8.array(z8.object({
899
+ id: z8.string(),
900
+ mode: z8.enum(sessionModeValues),
901
+ provider: z8.enum(providerValues),
902
+ ptyOwner: z8.enum(ptyOwnerValues).optional(),
903
+ cwd: z8.string().optional(),
904
+ name: z8.string().optional(),
905
+ nameLocked: z8.boolean().optional(),
906
+ state: z8.enum(sessionStateValues)
907
+ }))
908
+ }),
909
+ // PTY 会话订阅,client -> proxy,触发 terminal serialize() 返回当前状态
910
+ control("session_subscribe", { sessionId: IdSchema, requestId: IdSchema.optional() }, "client_to_proxy"),
911
+ // PTY 会话快照,proxy -> client,serialize() 的全量终端状态
912
+ control("session_snapshot", {
913
+ sessionId: IdSchema,
914
+ cols: z8.number().int().positive(),
915
+ rows: z8.number().int().positive(),
916
+ data: z8.string(),
917
+ outputSeq: z8.number().int().nonnegative(),
918
+ requestId: IdSchema.optional()
919
+ }, "proxy_to_client")
920
+ ];
921
+ var relayControlSchemas = relayControlDefinitions.map((definition) => definition.schema);
922
+ var RelayControlSchema = z8.discriminatedUnion("type", relayControlSchemas);
923
+ var ProxyToClientRelayControlTypes = new Set(relayControlDefinitions.filter((definition) => definition.directions.has("proxy_to_client")).map((definition) => definition.type));
924
+ function isProxyToClientRelayControlType(type) {
925
+ return ProxyToClientRelayControlTypes.has(type);
926
+ }
927
+ var ClientToProxyRelayControlTypes = new Set(relayControlDefinitions.filter((definition) => definition.directions.has("client_to_proxy")).map((definition) => definition.type));
928
+ function isClientToProxyRelayControlType(type) {
929
+ return ClientToProxyRelayControlTypes.has(type);
930
+ }
931
+
932
+ // ../../packages/shared/dist/binary-frame.js
933
+ var SID_LEN_BYTES = 1;
934
+ var SEQ_BYTES = 4;
935
+ var HEADER_FIXED_BYTES = SID_LEN_BYTES + SEQ_BYTES;
936
+
937
+ // ../../packages/shared/dist/state-machine.js
938
+ function computeAbsorbingSet(transitions) {
939
+ const absorbing = /* @__PURE__ */ new Set();
940
+ const entries = Object.entries(transitions);
941
+ for (const [s, outs] of entries) {
942
+ if (outs.length === 0)
943
+ absorbing.add(s);
944
+ }
945
+ let changed = true;
946
+ while (changed) {
947
+ changed = false;
948
+ for (const [s, outs] of entries) {
949
+ if (absorbing.has(s))
950
+ continue;
951
+ if (outs.length > 0 && outs.every((t) => absorbing.has(t))) {
952
+ absorbing.add(s);
953
+ changed = true;
954
+ }
955
+ }
956
+ }
957
+ return absorbing;
958
+ }
959
+ function defineFSM(transitions) {
960
+ const absorbing = computeAbsorbingSet(transitions);
961
+ return {
962
+ canTransition: (from, to) => transitions[from]?.includes(to) ?? false,
963
+ isAbsorbing: (state) => absorbing.has(state)
964
+ };
965
+ }
966
+
967
+ // src/registry.ts
968
+ var proxyConnectionFSM = defineFSM({
969
+ online: ["offline"],
970
+ offline: ["online"]
971
+ });
972
+ var RelayRegistry = class {
973
+ proxyStates = /* @__PURE__ */ new Map();
974
+ clientBindings = /* @__PURE__ */ new Map();
975
+ connectedClients = /* @__PURE__ */ new Set();
976
+ registerProxy(proxyId, ws, name) {
977
+ const existing = this.proxyStates.get(proxyId);
978
+ if (existing) {
979
+ if (existing.ws && existing.ws.readyState === WebSocket.OPEN) {
980
+ existing.ws.terminate();
981
+ }
982
+ existing.ws = ws;
983
+ existing.connectionState = "online";
984
+ existing.disconnectedAt = null;
985
+ if (name !== void 0) existing.name = name;
986
+ return "reconnected";
987
+ }
988
+ this.proxyStates.set(proxyId, {
989
+ ws,
990
+ connectionState: "online",
991
+ sessions: /* @__PURE__ */ new Set(),
992
+ disconnectedAt: null,
993
+ name
994
+ });
995
+ return "new";
996
+ }
997
+ // 显式状态转换,校验 from 状态匹配后更新 connectionState
998
+ transitionProxy(proxyId, from, to) {
999
+ if (!proxyConnectionFSM.canTransition(from, to)) {
1000
+ throw new Error(`Invalid proxy transition: ${from} -> ${to}`);
1001
+ }
1002
+ const state = this.proxyStates.get(proxyId);
1003
+ if (!state) {
1004
+ throw new Error(`Proxy not found: ${proxyId}`);
1005
+ }
1006
+ if (state.connectionState !== from) {
1007
+ throw new Error(
1008
+ `Proxy ${proxyId} state mismatch: expected ${from}, actual ${state.connectionState}`
1009
+ );
1010
+ }
1011
+ state.connectionState = to;
1012
+ if (to === "offline") {
1013
+ state.ws = null;
1014
+ state.disconnectedAt = Date.now();
1015
+ }
1016
+ }
1017
+ getProxyConnectionState(proxyId) {
1018
+ return this.proxyStates.get(proxyId)?.connectionState;
1019
+ }
1020
+ getClientConnectionState(clientId) {
1021
+ return this.clientBindings.get(clientId)?.connectionState;
1022
+ }
1023
+ // 彻底清理 proxy 状态和客户端绑定
1024
+ cleanupProxy(proxyId) {
1025
+ const state = this.proxyStates.get(proxyId);
1026
+ if (!state) return;
1027
+ for (const [clientId, binding] of this.clientBindings) {
1028
+ if (binding.proxyId === proxyId) {
1029
+ this.clientBindings.delete(clientId);
1030
+ }
1031
+ }
1032
+ this.proxyStates.delete(proxyId);
1033
+ }
1034
+ unregisterProxy(proxyId) {
1035
+ this.cleanupProxy(proxyId);
1036
+ }
1037
+ getProxy(proxyId) {
1038
+ const state = this.proxyStates.get(proxyId);
1039
+ return state?.ws ?? void 0;
1040
+ }
1041
+ isProxyOnline(proxyId) {
1042
+ const state = this.proxyStates.get(proxyId);
1043
+ if (!state || state.connectionState !== "online") return false;
1044
+ if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return false;
1045
+ return true;
1046
+ }
1047
+ // proxy 是否存在(含宽限期中的)
1048
+ hasProxy(proxyId) {
1049
+ return this.proxyStates.has(proxyId);
1050
+ }
1051
+ listProxies() {
1052
+ return Array.from(this.proxyStates.keys());
1053
+ }
1054
+ // 返回 proxyId、name、online 的列表,用于 proxy_list_response
1055
+ listProxiesWithName() {
1056
+ return Array.from(this.proxyStates.entries()).map(([proxyId, state]) => ({
1057
+ proxyId,
1058
+ ...state.name !== void 0 ? { name: state.name } : {},
1059
+ online: state.connectionState === "online"
1060
+ }));
1061
+ }
1062
+ getProxyName(proxyId) {
1063
+ return this.proxyStates.get(proxyId)?.name;
1064
+ }
1065
+ // 将 sessionId 关联到 proxy
1066
+ addSessionToProxy(proxyId, sessionId) {
1067
+ const state = this.proxyStates.get(proxyId);
1068
+ if (state) {
1069
+ state.sessions.add(sessionId);
1070
+ }
1071
+ }
1072
+ // 通过 sessionId 反查所属 proxyId
1073
+ getProxyForSession(sessionId) {
1074
+ for (const [proxyId, state] of this.proxyStates) {
1075
+ if (state.sessions.has(sessionId)) {
1076
+ return proxyId;
1077
+ }
1078
+ }
1079
+ return void 0;
1080
+ }
1081
+ // 获取 proxy 关联的所有 sessionId
1082
+ getSessionsForProxy(proxyId) {
1083
+ const state = this.proxyStates.get(proxyId);
1084
+ return state ? Array.from(state.sessions) : [];
1085
+ }
1086
+ // clientId 绑定方式
1087
+ bindClientById(clientId, proxyId, ws) {
1088
+ if (!this.proxyStates.has(proxyId)) {
1089
+ return false;
1090
+ }
1091
+ this.clientBindings.set(clientId, { proxyId, ws, connectionState: "bound" });
1092
+ return true;
1093
+ }
1094
+ updateClientSocket(clientId, ws) {
1095
+ const binding = this.clientBindings.get(clientId);
1096
+ if (binding) {
1097
+ binding.ws = ws;
1098
+ }
1099
+ }
1100
+ // 断开客户端 WebSocket 但保留绑定关系,重连时可恢复
1101
+ unbindClientById(clientId) {
1102
+ const binding = this.clientBindings.get(clientId);
1103
+ if (binding) {
1104
+ binding.ws = null;
1105
+ }
1106
+ }
1107
+ getClientBinding(clientId) {
1108
+ return this.clientBindings.get(clientId);
1109
+ }
1110
+ // 获取绑定到指定 proxy 的所有活跃客户端 WebSocket
1111
+ getClientsForProxy(proxyId) {
1112
+ const clients = [];
1113
+ for (const [, binding] of this.clientBindings) {
1114
+ if (binding.proxyId === proxyId && binding.ws && binding.ws.readyState === WebSocket.OPEN) {
1115
+ clients.push(binding.ws);
1116
+ }
1117
+ }
1118
+ return clients;
1119
+ }
1120
+ countClients() {
1121
+ let count = 0;
1122
+ for (const [, binding] of this.clientBindings) {
1123
+ if (binding.ws && binding.ws.readyState === WebSocket.OPEN) count++;
1124
+ }
1125
+ return count;
1126
+ }
1127
+ addClientWs(ws) {
1128
+ this.connectedClients.add(ws);
1129
+ }
1130
+ removeClientWs(ws) {
1131
+ this.connectedClients.delete(ws);
1132
+ }
1133
+ getAllClientWs() {
1134
+ const clients = [];
1135
+ for (const ws of this.connectedClients) {
1136
+ if (ws.readyState === WebSocket.OPEN) {
1137
+ clients.push(ws);
1138
+ }
1139
+ }
1140
+ return clients;
1141
+ }
1142
+ // 获取单个 proxy 的详细状态信息
1143
+ getProxyDetail(proxyId) {
1144
+ const state = this.proxyStates.get(proxyId);
1145
+ if (!state) return void 0;
1146
+ return {
1147
+ proxyId,
1148
+ ...state.name !== void 0 ? { name: state.name } : {},
1149
+ online: state.connectionState === "online",
1150
+ connectionState: state.connectionState,
1151
+ sessions: Array.from(state.sessions),
1152
+ disconnectedAt: state.disconnectedAt
1153
+ };
1154
+ }
1155
+ // 获取所有客户端绑定的详细信息
1156
+ getClientDetails() {
1157
+ const details = [];
1158
+ for (const [clientId, binding] of this.clientBindings) {
1159
+ details.push({
1160
+ clientId,
1161
+ proxyId: binding.proxyId,
1162
+ online: binding.ws !== null && binding.ws !== void 0 && binding.ws.readyState === WebSocket.OPEN,
1163
+ connectionState: binding.connectionState
1164
+ });
1165
+ }
1166
+ return details;
1167
+ }
1168
+ };
1169
+
1170
+ // src/health.ts
1171
+ import { Router } from "express";
1172
+
1173
+ // src/version.ts
1174
+ import { readFileSync } from "fs";
1175
+ import { dirname, join } from "path";
1176
+ import { fileURLToPath } from "url";
1177
+ function readRelayVersion() {
1178
+ const packageJsonPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
1179
+ const pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
1180
+ return pkg.version ?? "unknown";
1181
+ }
1182
+ var RELAY_VERSION = readRelayVersion();
1183
+
1184
+ // src/health.ts
1185
+ function bearerToken(authHeader) {
1186
+ const match = /^Bearer\s+(.+)$/i.exec(authHeader ?? "");
1187
+ return match?.[1] ?? null;
1188
+ }
1189
+ function healthRouter(registry, options = {}) {
1190
+ const router = Router();
1191
+ const proxyTokenRequired = options.proxyTokenRequired ?? false;
1192
+ const clientTokenRequired = options.clientTokenRequired ?? false;
1193
+ router.get("/health", (_req, res) => {
1194
+ res.json({
1195
+ status: "ok",
1196
+ version: RELAY_VERSION,
1197
+ uptime: process.uptime(),
1198
+ auth: {
1199
+ proxyTokenRequired,
1200
+ clientTokenRequired
1201
+ }
1202
+ });
1203
+ });
1204
+ router.get("/api/auth/client", (req, res) => {
1205
+ if (!clientTokenRequired) {
1206
+ res.status(204).end();
1207
+ return;
1208
+ }
1209
+ const token = bearerToken(req.get("authorization"));
1210
+ if (options.validateClientToken?.(token)) {
1211
+ res.status(204).end();
1212
+ return;
1213
+ }
1214
+ res.status(401).json({ error: "invalid_client_token" });
1215
+ });
1216
+ router.get("/api/admin/client-token", (req, res) => {
1217
+ if (!proxyTokenRequired) {
1218
+ res.status(401).json({ error: "proxy_token_required" });
1219
+ return;
1220
+ }
1221
+ const token = bearerToken(req.get("authorization"));
1222
+ if (!options.validateProxyToken?.(token)) {
1223
+ res.status(401).json({ error: "invalid_proxy_token" });
1224
+ return;
1225
+ }
1226
+ const clientToken = options.getClientToken?.() ?? null;
1227
+ if (!clientToken) {
1228
+ res.status(204).end();
1229
+ return;
1230
+ }
1231
+ res.json({ clientToken });
1232
+ });
1233
+ function requireProxyTokenIfConfigured(req, res) {
1234
+ if (!proxyTokenRequired) return true;
1235
+ const token = bearerToken(req.get("authorization"));
1236
+ if (options.validateProxyToken?.(token)) return true;
1237
+ res.status(401).json({ error: "invalid_proxy_token" });
1238
+ return false;
1239
+ }
1240
+ router.get("/status", (_req, res) => {
1241
+ res.json({
1242
+ version: RELAY_VERSION,
1243
+ proxyCount: registry.listProxies().length,
1244
+ clientCount: registry.countClients(),
1245
+ uptime: process.uptime()
1246
+ });
1247
+ });
1248
+ router.get("/api/status", (req, res) => {
1249
+ if (!requireProxyTokenIfConfigured(req, res)) return;
1250
+ res.json({
1251
+ version: RELAY_VERSION,
1252
+ proxyCount: registry.listProxies().length,
1253
+ clientCount: registry.countClients(),
1254
+ uptime: process.uptime(),
1255
+ bindings: registry.getClientDetails()
1256
+ });
1257
+ });
1258
+ router.get("/api/proxies", (req, res) => {
1259
+ if (!requireProxyTokenIfConfigured(req, res)) return;
1260
+ const proxyIds = registry.listProxies();
1261
+ const details = proxyIds.map((id) => registry.getProxyDetail(id)).filter((d) => d !== void 0);
1262
+ res.json(details);
1263
+ });
1264
+ router.get("/api/clients", (req, res) => {
1265
+ if (!requireProxyTokenIfConfigured(req, res)) return;
1266
+ res.json(registry.getClientDetails());
1267
+ });
1268
+ return router;
1269
+ }
1270
+
1271
+ // src/handlers/proxy.ts
1272
+ import { WebSocket as WebSocket4 } from "ws";
1273
+
1274
+ // src/router.ts
1275
+ import { WebSocket as WebSocket2 } from "ws";
1276
+ function parseMessage(data) {
1277
+ let parsed;
1278
+ try {
1279
+ parsed = JSON.parse(data);
1280
+ } catch {
1281
+ return { kind: "invalid", error: "Invalid JSON" };
1282
+ }
1283
+ const controlResult = RelayControlSchema.safeParse(parsed);
1284
+ if (controlResult.success) {
1285
+ return { kind: "control", message: controlResult.data };
1286
+ }
1287
+ const envelopeResult = MessageEnvelopeSchema.safeParse(parsed);
1288
+ if (envelopeResult.success) {
1289
+ return { kind: "envelope", message: envelopeResult.data, raw: data };
1290
+ }
1291
+ return { kind: "invalid", error: "Message matches neither RelayControl nor MessageEnvelope" };
1292
+ }
1293
+ function extractRequestIdFromRaw(raw) {
1294
+ try {
1295
+ const parsed = JSON.parse(raw);
1296
+ if (parsed && typeof parsed === "object" && typeof parsed.requestId === "string") {
1297
+ return parsed.requestId;
1298
+ }
1299
+ } catch {
1300
+ }
1301
+ return void 0;
1302
+ }
1303
+ function routeProxyMessage(raw, proxyId, registry, logger, chaos) {
1304
+ const result = parseMessage(raw);
1305
+ if (result.kind === "invalid") {
1306
+ logger.warn({ proxyId, error: result.error }, "Invalid message from proxy");
1307
+ return;
1308
+ }
1309
+ if (result.kind === "control") {
1310
+ logger.warn({ proxyId }, "Control message in routeProxyMessage, should be handled by handler");
1311
+ return;
1312
+ }
1313
+ const { message } = result;
1314
+ if ("sessionId" in message) {
1315
+ registry.addSessionToProxy(proxyId, message.sessionId);
1316
+ }
1317
+ const clients = registry.getClientsForProxy(proxyId);
1318
+ for (const clientWs of clients) {
1319
+ if (clientWs.readyState === WebSocket2.OPEN) {
1320
+ if (chaos) chaos.send(clientWs, raw, { direction: "proxy_to_client", type: message.type });
1321
+ else clientWs.send(raw);
1322
+ }
1323
+ }
1324
+ }
1325
+ function routeClientMessage(raw, proxyId, clientWs, registry, logger, chaos) {
1326
+ const result = parseMessage(raw);
1327
+ if (result.kind === "invalid") {
1328
+ logger.warn({ error: result.error }, "Invalid message from client");
1329
+ const requestId = extractRequestIdFromRaw(raw);
1330
+ clientWs.send(
1331
+ JSON.stringify({
1332
+ type: "relay_error",
1333
+ code: RelayErrorCode.INVALID_MESSAGE,
1334
+ message: result.error,
1335
+ ...requestId ? { requestId } : {}
1336
+ })
1337
+ );
1338
+ return;
1339
+ }
1340
+ if (result.kind === "control") {
1341
+ logger.warn("Control message in routeClientMessage, should be handled by handler");
1342
+ return;
1343
+ }
1344
+ const proxyWs = registry.getProxy(proxyId);
1345
+ if (!proxyWs || proxyWs.readyState !== WebSocket2.OPEN) {
1346
+ clientWs.send(
1347
+ JSON.stringify({
1348
+ type: "relay_error",
1349
+ code: RelayErrorCode.PROXY_OFFLINE,
1350
+ message: `Proxy ${proxyId} is not available`
1351
+ })
1352
+ );
1353
+ return;
1354
+ }
1355
+ if (chaos) chaos.send(proxyWs, raw, { direction: "client_to_proxy", type: result.message.type });
1356
+ else proxyWs.send(raw);
1357
+ }
1358
+
1359
+ // src/latency-probes.ts
1360
+ import { performance } from "perf_hooks";
1361
+ import { WebSocket as WebSocket3 } from "ws";
1362
+ var DEFAULT_RELAY_PROXY_PROBE_TIMEOUT_MS = 3e3;
1363
+ var pendingRelayProxyProbes = /* @__PURE__ */ new Map();
1364
+ function keyFor(proxyId, requestId) {
1365
+ return `${proxyId}:${requestId}`;
1366
+ }
1367
+ function sendClientResponse(clientWs, response, chaos) {
1368
+ if (clientWs.readyState !== WebSocket3.OPEN) return;
1369
+ const raw = serializeControl(response);
1370
+ if (chaos) {
1371
+ chaos.send(clientWs, raw, {
1372
+ direction: "proxy_to_client",
1373
+ type: "latency_relay_proxy_response"
1374
+ });
1375
+ return;
1376
+ }
1377
+ clientWs.send(raw);
1378
+ }
1379
+ function startRelayProxyLatencyProbe({
1380
+ requestId,
1381
+ proxyId,
1382
+ proxyWs,
1383
+ clientWs,
1384
+ logger,
1385
+ chaos,
1386
+ timeoutMs = DEFAULT_RELAY_PROXY_PROBE_TIMEOUT_MS
1387
+ }) {
1388
+ const key = keyFor(proxyId, requestId);
1389
+ const existing = pendingRelayProxyProbes.get(key);
1390
+ if (existing) {
1391
+ clearTimeout(existing.timer);
1392
+ pendingRelayProxyProbes.delete(key);
1393
+ }
1394
+ const startedAt = performance.now();
1395
+ const timer = setTimeout(() => {
1396
+ pendingRelayProxyProbes.delete(key);
1397
+ sendClientResponse(
1398
+ clientWs,
1399
+ {
1400
+ type: "latency_relay_proxy_response",
1401
+ requestId,
1402
+ success: false,
1403
+ error: "Relay \u5230\u5F00\u53D1\u673A\u6D4B\u901F\u8D85\u65F6"
1404
+ },
1405
+ chaos
1406
+ );
1407
+ logger.warn({ proxyId, requestId }, "Relay-proxy latency probe timed out");
1408
+ }, timeoutMs);
1409
+ pendingRelayProxyProbes.set(key, {
1410
+ proxyId,
1411
+ requestId,
1412
+ clientWs,
1413
+ startedAt,
1414
+ timer,
1415
+ chaos
1416
+ });
1417
+ const raw = serializeControl({
1418
+ type: "latency_relay_proxy_ping",
1419
+ requestId,
1420
+ relayNow: Date.now()
1421
+ });
1422
+ if (chaos) {
1423
+ chaos.send(proxyWs, raw, { direction: "client_to_proxy", type: "latency_relay_proxy_ping" });
1424
+ return;
1425
+ }
1426
+ proxyWs.send(raw);
1427
+ }
1428
+ function completeRelayProxyLatencyProbe({
1429
+ proxyId,
1430
+ requestId,
1431
+ logger
1432
+ }) {
1433
+ const key = keyFor(proxyId, requestId);
1434
+ const pending = pendingRelayProxyProbes.get(key);
1435
+ if (!pending) return false;
1436
+ pendingRelayProxyProbes.delete(key);
1437
+ clearTimeout(pending.timer);
1438
+ const rttMs = performance.now() - pending.startedAt;
1439
+ sendClientResponse(
1440
+ pending.clientWs,
1441
+ {
1442
+ type: "latency_relay_proxy_response",
1443
+ requestId,
1444
+ success: true,
1445
+ rttMs
1446
+ },
1447
+ pending.chaos
1448
+ );
1449
+ logger.debug({ proxyId, requestId, rttMs }, "Relay-proxy latency probe completed");
1450
+ return true;
1451
+ }
1452
+
1453
+ // src/handlers/proxy.ts
1454
+ var MAX_BINARY_FRAME_SIZE = 10 * 1024 * 1024;
1455
+ var MAX_JSON_MESSAGE_SIZE = 1 * 1024 * 1024;
1456
+ function notifyClientsProxyOffline(proxyId, registry, logger, chaos) {
1457
+ const clients = registry.getClientsForProxy(proxyId);
1458
+ const msg = JSON.stringify({ type: "proxy_offline", proxyId });
1459
+ for (const clientWs of clients) {
1460
+ if (chaos) chaos.send(clientWs, msg, { direction: "proxy_to_client", type: "proxy_offline" });
1461
+ else clientWs.send(msg);
1462
+ }
1463
+ logger.info({ proxyId, clientCount: clients.length }, "Notified clients of proxy offline");
1464
+ }
1465
+ function notifyClientsProxyOnline(proxyId, registry, logger, chaos) {
1466
+ const clients = registry.getClientsForProxy(proxyId);
1467
+ const msg = JSON.stringify({ type: "proxy_online", proxyId });
1468
+ for (const clientWs of clients) {
1469
+ if (chaos) chaos.send(clientWs, msg, { direction: "proxy_to_client", type: "proxy_online" });
1470
+ else clientWs.send(msg);
1471
+ }
1472
+ logger.info({ proxyId, clientCount: clients.length }, "Notified clients of proxy online");
1473
+ }
1474
+ function broadcastProxyList(registry, chaos) {
1475
+ const proxies = registry.listProxiesWithName().map((p) => ({
1476
+ ...p,
1477
+ sessions: registry.getSessionsForProxy(p.proxyId)
1478
+ }));
1479
+ const msg = JSON.stringify({ type: "proxy_list_response", proxies });
1480
+ for (const clientWs of registry.getAllClientWs()) {
1481
+ if (chaos)
1482
+ chaos.send(clientWs, msg, { direction: "proxy_to_client", type: "proxy_list_response" });
1483
+ else clientWs.send(msg);
1484
+ }
1485
+ }
1486
+ function rejectNotRegistered(ws) {
1487
+ ws.send(
1488
+ JSON.stringify({
1489
+ type: "relay_error",
1490
+ code: RelayErrorCode.NOT_REGISTERED,
1491
+ message: "Proxy must register before sending messages"
1492
+ })
1493
+ );
1494
+ }
1495
+ function handleProxyConnection(ws, registry, logger, chaos) {
1496
+ const proxyWs = ws;
1497
+ proxyWs.isAlive = true;
1498
+ proxyWs.on("pong", () => {
1499
+ proxyWs.isAlive = true;
1500
+ });
1501
+ proxyWs.on("message", (data, isBinary) => {
1502
+ if (isBinary) {
1503
+ if (data.length < 2 || data.length > MAX_BINARY_FRAME_SIZE) {
1504
+ logger.warn({ size: data.length }, "Binary frame rejected: invalid size");
1505
+ return;
1506
+ }
1507
+ const sessionIdLen = data[0];
1508
+ if (sessionIdLen === 0 || sessionIdLen > 255 || data.length < 1 + sessionIdLen) {
1509
+ logger.warn(
1510
+ { sessionIdLen, dataLen: data.length },
1511
+ "Binary frame rejected: malformed sessionId prefix"
1512
+ );
1513
+ return;
1514
+ }
1515
+ if (!proxyWs.proxyId) {
1516
+ logger.warn("Binary frame from unregistered proxy, dropped");
1517
+ return;
1518
+ }
1519
+ const clients = registry.getClientsForProxy(proxyWs.proxyId);
1520
+ for (const clientWs of clients) {
1521
+ if (clientWs.readyState === WebSocket4.OPEN) {
1522
+ clientWs.send(data);
1523
+ }
1524
+ }
1525
+ return;
1526
+ }
1527
+ if (data.length > MAX_JSON_MESSAGE_SIZE) {
1528
+ logger.warn(
1529
+ { size: data.length, proxyId: proxyWs.proxyId },
1530
+ "JSON message rejected: exceeds max size"
1531
+ );
1532
+ return;
1533
+ }
1534
+ const raw = data.toString();
1535
+ const result = parseMessage(raw);
1536
+ if (result.kind === "control" && result.message.type === "proxy_register") {
1537
+ const { proxyId, name } = result.message;
1538
+ const status = registry.registerProxy(proxyId, proxyWs, name);
1539
+ proxyWs.proxyId = proxyId;
1540
+ logger.info({ proxyId, status }, "Proxy registered");
1541
+ proxyWs.send(
1542
+ serializeControl({
1543
+ type: "proxy_register_response",
1544
+ status
1545
+ })
1546
+ );
1547
+ if (status === "reconnected") {
1548
+ notifyClientsProxyOnline(proxyId, registry, logger, chaos);
1549
+ }
1550
+ broadcastProxyList(registry, chaos);
1551
+ return;
1552
+ }
1553
+ if (result.kind === "control" && result.message.type === "proxy_disconnect") {
1554
+ if (proxyWs.proxyId) {
1555
+ notifyClientsProxyOffline(proxyWs.proxyId, registry, logger, chaos);
1556
+ registry.unregisterProxy(proxyWs.proxyId);
1557
+ logger.info(
1558
+ { proxyId: proxyWs.proxyId },
1559
+ "Proxy gracefully disconnected, resources cleaned"
1560
+ );
1561
+ proxyWs.proxyId = void 0;
1562
+ broadcastProxyList(registry, chaos);
1563
+ }
1564
+ return;
1565
+ }
1566
+ if (result.kind === "control" && result.message.type === "session_sync") {
1567
+ if (!proxyWs.proxyId) return;
1568
+ const { sessions } = result.message;
1569
+ for (const s of sessions) {
1570
+ registry.addSessionToProxy(proxyWs.proxyId, s.id);
1571
+ }
1572
+ logger.info({ proxyId: proxyWs.proxyId, count: sessions.length }, "Session sync received");
1573
+ return;
1574
+ }
1575
+ if (result.kind === "control" && result.message.type === "latency_relay_proxy_pong") {
1576
+ if (!proxyWs.proxyId) {
1577
+ rejectNotRegistered(proxyWs);
1578
+ return;
1579
+ }
1580
+ const completed = completeRelayProxyLatencyProbe({
1581
+ proxyId: proxyWs.proxyId,
1582
+ requestId: result.message.requestId,
1583
+ logger
1584
+ });
1585
+ if (!completed) {
1586
+ logger.debug(
1587
+ { proxyId: proxyWs.proxyId, requestId: result.message.requestId },
1588
+ "Unmatched relay-proxy latency pong ignored"
1589
+ );
1590
+ }
1591
+ return;
1592
+ }
1593
+ if (result.kind === "control") {
1594
+ if (isProxyToClientRelayControlType(result.message.type)) {
1595
+ if (!proxyWs.proxyId) {
1596
+ rejectNotRegistered(proxyWs);
1597
+ return;
1598
+ }
1599
+ const clients = registry.getClientsForProxy(proxyWs.proxyId);
1600
+ for (const clientWs of clients) {
1601
+ if (clientWs.readyState === WebSocket4.OPEN) {
1602
+ if (chaos) {
1603
+ chaos.send(clientWs, raw, {
1604
+ direction: "proxy_to_client",
1605
+ type: result.message.type
1606
+ });
1607
+ } else {
1608
+ clientWs.send(raw);
1609
+ }
1610
+ }
1611
+ }
1612
+ logger.info(
1613
+ { proxyId: proxyWs.proxyId, type: result.message.type, clientCount: clients.length },
1614
+ "Forwarded control message from proxy to clients"
1615
+ );
1616
+ return;
1617
+ }
1618
+ logger.warn({ type: result.message.type }, "Unexpected control message from proxy");
1619
+ return;
1620
+ }
1621
+ if (result.kind === "envelope") {
1622
+ if (!proxyWs.proxyId) {
1623
+ rejectNotRegistered(proxyWs);
1624
+ return;
1625
+ }
1626
+ routeProxyMessage(raw, proxyWs.proxyId, registry, logger, chaos);
1627
+ return;
1628
+ }
1629
+ if (result.kind === "invalid") {
1630
+ logger.error({ error: result.error, raw: raw.slice(0, 200) }, "Invalid message from proxy");
1631
+ proxyWs.send(
1632
+ JSON.stringify({
1633
+ type: "relay_error",
1634
+ code: RelayErrorCode.INVALID_MESSAGE,
1635
+ message: `${result.error} | raw: ${raw.slice(0, 200)}`
1636
+ })
1637
+ );
1638
+ return;
1639
+ }
1640
+ });
1641
+ proxyWs.on("close", (code, reason) => {
1642
+ if (!proxyWs.proxyId) return;
1643
+ const closeMeta = { code, reason: reason.toString() || void 0 };
1644
+ const current = registry.getProxy(proxyWs.proxyId);
1645
+ if (current && current !== proxyWs) {
1646
+ logger.info(
1647
+ { proxyId: proxyWs.proxyId, ...closeMeta },
1648
+ "Old proxy ws closed after being superseded by reconnect, skipping offline transition"
1649
+ );
1650
+ return;
1651
+ }
1652
+ notifyClientsProxyOffline(proxyWs.proxyId, registry, logger, chaos);
1653
+ try {
1654
+ registry.transitionProxy(proxyWs.proxyId, "online", "offline");
1655
+ } catch (err) {
1656
+ logger.debug(
1657
+ { proxyId: proxyWs.proxyId, err: String(err) },
1658
+ "transitionProxy on close skipped"
1659
+ );
1660
+ }
1661
+ logger.info(
1662
+ { proxyId: proxyWs.proxyId, ...closeMeta },
1663
+ "Proxy disconnected, state preserved for reconnect"
1664
+ );
1665
+ broadcastProxyList(registry, chaos);
1666
+ });
1667
+ proxyWs.on("error", (err) => {
1668
+ logger.error({ err, proxyId: proxyWs.proxyId }, "Proxy WebSocket error");
1669
+ });
1670
+ }
1671
+
1672
+ // src/handlers/client.ts
1673
+ import { WebSocket as WebSocket7 } from "ws";
1674
+ import { nanoid as nanoid3 } from "nanoid";
1675
+
1676
+ // src/voice/bailian-asr.ts
1677
+ import { EventEmitter } from "events";
1678
+ import { nanoid } from "nanoid";
1679
+ import { WebSocket as WebSocket5 } from "ws";
1680
+
1681
+ // src/voice/bailian-endpoints.ts
1682
+ var BAILIAN_HOSTS = {
1683
+ cn: "wss://dashscope.aliyuncs.com",
1684
+ intl: "wss://dashscope-intl.aliyuncs.com"
1685
+ };
1686
+ function bailianRealtimeUrl(region, model) {
1687
+ const url = `${BAILIAN_HOSTS[region]}/api-ws/v1/realtime`;
1688
+ return model ? `${url}?model=${encodeURIComponent(model)}` : url;
1689
+ }
1690
+ function bailianInferenceUrl(region) {
1691
+ return `${BAILIAN_HOSTS[region]}/api-ws/v1/inference`;
1692
+ }
1693
+
1694
+ // src/voice/bailian-asr.ts
1695
+ var OPEN = 1;
1696
+ var END_OF_SPEECH_SILENCE_MS = 1200;
1697
+ function defaultSocketFactory(url, options) {
1698
+ return new WebSocket5(url, options);
1699
+ }
1700
+ function extractRealtimePreview(payload) {
1701
+ if (!payload || typeof payload !== "object") return null;
1702
+ const record = payload;
1703
+ if (typeof record.text === "string" || typeof record.stash === "string") {
1704
+ const text = typeof record.text === "string" ? record.text : "";
1705
+ const stash = typeof record.stash === "string" ? record.stash : "";
1706
+ const preview = `${text}${stash}`;
1707
+ return preview.length > 0 ? preview : null;
1708
+ }
1709
+ const candidates = [
1710
+ record.text,
1711
+ record.transcript,
1712
+ record.delta,
1713
+ record.output && typeof record.output === "object" && record.output.text
1714
+ ];
1715
+ for (const candidate of candidates) {
1716
+ if (typeof candidate === "string" && candidate.length > 0) return candidate;
1717
+ }
1718
+ return null;
1719
+ }
1720
+ function extractFinalText(payload) {
1721
+ if (!payload || typeof payload !== "object") return null;
1722
+ const record = payload;
1723
+ const candidates = [
1724
+ record.transcript,
1725
+ record.text,
1726
+ record.output && typeof record.output === "object" && record.output.text
1727
+ ];
1728
+ for (const candidate of candidates) {
1729
+ if (typeof candidate === "string" && candidate.length > 0) return candidate;
1730
+ }
1731
+ return null;
1732
+ }
1733
+ function extractError(payload) {
1734
+ if (!payload || typeof payload !== "object") return new Error("Bailian ASR error");
1735
+ const record = payload;
1736
+ const nested = record.error && typeof record.error === "object" ? record.error : null;
1737
+ let message = "Bailian ASR error";
1738
+ if (nested && typeof nested.message === "string") {
1739
+ message = nested.message;
1740
+ } else if (typeof record.message === "string") {
1741
+ message = record.message;
1742
+ }
1743
+ return new Error(message);
1744
+ }
1745
+ var BailianAsrClientImpl = class extends EventEmitter {
1746
+ constructor(config, socketFactory, eventIdFactory) {
1747
+ super();
1748
+ this.config = config;
1749
+ this.eventIdFactory = eventIdFactory;
1750
+ this.socket = socketFactory(bailianRealtimeUrl(config.region, config.model), {
1751
+ headers: {
1752
+ Authorization: `bearer ${config.apiKey}`,
1753
+ "OpenAI-Beta": "realtime=v1"
1754
+ }
1755
+ });
1756
+ this.socket.on("open", () => this.handleOpen());
1757
+ this.socket.on("message", (data) => this.handleMessage(data));
1758
+ this.socket.on(
1759
+ "error",
1760
+ (err) => this.emit("error", err instanceof Error ? err : new Error(String(err)))
1761
+ );
1762
+ this.socket.on(
1763
+ "close",
1764
+ (code, reason) => this.emit("closed", code, reason?.toString("utf8"))
1765
+ );
1766
+ }
1767
+ config;
1768
+ eventIdFactory;
1769
+ socket;
1770
+ isOpen = false;
1771
+ isReady = false;
1772
+ pending = [];
1773
+ sendPcm(chunk) {
1774
+ this.sendWhenReady({
1775
+ event_id: this.eventIdFactory(),
1776
+ type: "input_audio_buffer.append",
1777
+ audio: chunk.toString("base64")
1778
+ });
1779
+ }
1780
+ stop() {
1781
+ this.sendWhenReady({
1782
+ event_id: this.eventIdFactory(),
1783
+ type: "session.finish"
1784
+ });
1785
+ }
1786
+ close() {
1787
+ this.socket.close();
1788
+ }
1789
+ handleOpen() {
1790
+ this.isOpen = true;
1791
+ this.sendNow({
1792
+ event_id: this.eventIdFactory(),
1793
+ type: "session.update",
1794
+ session: {
1795
+ modalities: ["text"],
1796
+ input_audio_format: "pcm",
1797
+ sample_rate: this.config.sampleRate,
1798
+ input_audio_transcription: {
1799
+ language: this.config.language
1800
+ },
1801
+ turn_detection: {
1802
+ type: "server_vad",
1803
+ threshold: 0,
1804
+ silence_duration_ms: END_OF_SPEECH_SILENCE_MS
1805
+ }
1806
+ }
1807
+ });
1808
+ }
1809
+ handleMessage(data) {
1810
+ const text = typeof data === "string" ? data : Buffer.isBuffer(data) ? data.toString("utf8") : "";
1811
+ if (!text) return;
1812
+ let payload;
1813
+ try {
1814
+ payload = JSON.parse(text);
1815
+ } catch {
1816
+ return;
1817
+ }
1818
+ const record = payload;
1819
+ const type = typeof record.type === "string" ? record.type : "";
1820
+ if (type.includes("error")) {
1821
+ this.emit("error", extractError(payload));
1822
+ return;
1823
+ }
1824
+ if (type === "conversation.item.input_audio_transcription.failed") {
1825
+ this.emit("error", extractError(payload));
1826
+ return;
1827
+ }
1828
+ if (type === "session.updated") {
1829
+ this.isReady = true;
1830
+ this.emit("ready");
1831
+ this.flushPending();
1832
+ return;
1833
+ }
1834
+ if (type === "conversation.item.input_audio_transcription.text") {
1835
+ const preview = extractRealtimePreview(payload);
1836
+ if (preview) this.emit("partial", preview);
1837
+ return;
1838
+ }
1839
+ if (type === "conversation.item.input_audio_transcription.completed" || type === "session.finished") {
1840
+ const transcript = extractFinalText(payload);
1841
+ if (transcript) {
1842
+ this.emit("final", transcript);
1843
+ }
1844
+ }
1845
+ }
1846
+ sendWhenReady(payload) {
1847
+ const message = JSON.stringify(payload);
1848
+ if ((this.isOpen || this.socket.readyState === OPEN) && this.isReady) {
1849
+ this.socket.send(message);
1850
+ return;
1851
+ }
1852
+ this.pending.push(message);
1853
+ }
1854
+ sendNow(payload) {
1855
+ this.socket.send(JSON.stringify(payload));
1856
+ }
1857
+ flushPending() {
1858
+ for (const message of this.pending) {
1859
+ this.socket.send(message);
1860
+ }
1861
+ this.pending = [];
1862
+ }
1863
+ };
1864
+ function createBailianAsrClient(config, options = {}) {
1865
+ return new BailianAsrClientImpl(
1866
+ config,
1867
+ options.socketFactory ?? defaultSocketFactory,
1868
+ options.eventIdFactory ?? (() => `event_${nanoid()}`)
1869
+ );
1870
+ }
1871
+
1872
+ // src/voice/bailian-tts.ts
1873
+ import { EventEmitter as EventEmitter2 } from "events";
1874
+ import { nanoid as nanoid2 } from "nanoid";
1875
+ import { WebSocket as WebSocket6 } from "ws";
1876
+ var OPEN2 = 1;
1877
+ function defaultSocketFactory2(url, options) {
1878
+ return new WebSocket6(url, options);
1879
+ }
1880
+ function errorFromPayload(payload) {
1881
+ if (!payload || typeof payload !== "object") return new Error("Bailian TTS error");
1882
+ const record = payload;
1883
+ const header = record.header && typeof record.header === "object" ? record.header : null;
1884
+ let message = "Bailian TTS error";
1885
+ if (header && typeof header.error_message === "string") {
1886
+ message = header.error_message;
1887
+ } else if (typeof record.message === "string") {
1888
+ message = record.message;
1889
+ }
1890
+ return new Error(message);
1891
+ }
1892
+ function eventFromPayload(payload) {
1893
+ if (!payload || typeof payload !== "object") return "";
1894
+ const record = payload;
1895
+ const header = record.header && typeof record.header === "object" ? record.header : null;
1896
+ const event = header ? header.event : void 0;
1897
+ return typeof event === "string" ? event : "";
1898
+ }
1899
+ var BailianTtsClientImpl = class extends EventEmitter2 {
1900
+ constructor(config, socketFactory, taskIdFactory) {
1901
+ super();
1902
+ this.config = config;
1903
+ this.taskIdFactory = taskIdFactory;
1904
+ this.socket = socketFactory(bailianInferenceUrl(config.region), {
1905
+ headers: { Authorization: `bearer ${config.apiKey}` }
1906
+ });
1907
+ this.socket.on("open", () => this.handleOpen());
1908
+ this.socket.on(
1909
+ "message",
1910
+ (data, isBinary) => this.handleMessage(data, isBinary)
1911
+ );
1912
+ this.socket.on(
1913
+ "error",
1914
+ (err) => this.emit("error", err instanceof Error ? err : new Error(String(err)))
1915
+ );
1916
+ this.socket.on(
1917
+ "close",
1918
+ (code, reason) => this.emit("closed", code, reason?.toString("utf8"))
1919
+ );
1920
+ }
1921
+ config;
1922
+ taskIdFactory;
1923
+ socket;
1924
+ isOpen = false;
1925
+ current = null;
1926
+ speak(text) {
1927
+ if (this.current) {
1928
+ throw new Error("Bailian TTS is already speaking");
1929
+ }
1930
+ this.current = { taskId: this.taskIdFactory(), text };
1931
+ if (this.isOpen || this.socket.readyState === OPEN2) {
1932
+ this.sendRunTask();
1933
+ }
1934
+ }
1935
+ close() {
1936
+ this.socket.close();
1937
+ }
1938
+ handleOpen() {
1939
+ this.isOpen = true;
1940
+ if (this.current) this.sendRunTask();
1941
+ }
1942
+ handleMessage(data, isBinary = false) {
1943
+ const text = this.tryDecodeText(data, isBinary);
1944
+ if (!text) {
1945
+ const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data);
1946
+ this.emit("audio", chunk);
1947
+ return;
1948
+ }
1949
+ let payload;
1950
+ try {
1951
+ payload = JSON.parse(text);
1952
+ } catch {
1953
+ const chunk = Buffer.isBuffer(data) ? data : Buffer.from(text);
1954
+ this.emit("audio", chunk);
1955
+ return;
1956
+ }
1957
+ const event = eventFromPayload(payload);
1958
+ if (event === "task-started") {
1959
+ this.emit("started");
1960
+ this.sendTextAndFinish();
1961
+ return;
1962
+ }
1963
+ if (event === "task-finished") {
1964
+ this.emit("finished");
1965
+ this.current = null;
1966
+ return;
1967
+ }
1968
+ if (event === "task-failed" || event === "task-error") {
1969
+ this.emit("error", errorFromPayload(payload));
1970
+ this.current = null;
1971
+ }
1972
+ }
1973
+ tryDecodeText(data, isBinary) {
1974
+ if (typeof data === "string") return data;
1975
+ if (isBinary) return null;
1976
+ if (Buffer.isBuffer(data)) return data.toString("utf8");
1977
+ return null;
1978
+ }
1979
+ sendRunTask() {
1980
+ if (!this.current) return;
1981
+ this.socket.send(
1982
+ JSON.stringify({
1983
+ header: {
1984
+ action: "run-task",
1985
+ task_id: this.current.taskId,
1986
+ streaming: "duplex"
1987
+ },
1988
+ payload: {
1989
+ task_group: "audio",
1990
+ task: "tts",
1991
+ function: "SpeechSynthesizer",
1992
+ model: this.config.model,
1993
+ input: {},
1994
+ parameters: {
1995
+ text_type: "PlainText",
1996
+ voice: this.config.voice,
1997
+ format: "pcm",
1998
+ sample_rate: this.config.sampleRate
1999
+ }
2000
+ }
2001
+ })
2002
+ );
2003
+ }
2004
+ sendTextAndFinish() {
2005
+ if (!this.current) return;
2006
+ this.socket.send(
2007
+ JSON.stringify({
2008
+ header: { action: "continue-task", task_id: this.current.taskId },
2009
+ payload: { input: { text: this.current.text } }
2010
+ })
2011
+ );
2012
+ this.socket.send(
2013
+ JSON.stringify({
2014
+ header: { action: "finish-task", task_id: this.current.taskId },
2015
+ payload: { input: {} }
2016
+ })
2017
+ );
2018
+ }
2019
+ };
2020
+ function createBailianTtsClient(config, options = {}) {
2021
+ return new BailianTtsClientImpl(
2022
+ config,
2023
+ options.socketFactory ?? defaultSocketFactory2,
2024
+ options.taskIdFactory ?? (() => nanoid2())
2025
+ );
2026
+ }
2027
+
2028
+ // src/voice/config-test.ts
2029
+ var TEST_SAMPLE_RATE = 16e3;
2030
+ var ASR_TEST_CHUNK_BYTES = 3200;
2031
+ var ASR_TEST_CHUNK_INTERVAL_MS = 100;
2032
+ function mergeVoiceConfigForTest(current, update) {
2033
+ return {
2034
+ ...current,
2035
+ provider: "aliyun-bailian",
2036
+ ...update?.clearApiKey ? { apiKey: void 0 } : {},
2037
+ ...update?.apiKey ? { apiKey: update.apiKey } : {},
2038
+ ...update?.region ? { region: update.region } : {},
2039
+ ...update?.asrModel ? { asrModel: update.asrModel } : {},
2040
+ ...update?.ttsModel ? { ttsModel: update.ttsModel } : {},
2041
+ ...update?.ttsVoice ? { ttsVoice: update.ttsVoice } : {},
2042
+ ...update?.turnIdleSeconds ? { turnIdleSeconds: update.turnIdleSeconds } : {}
2043
+ };
2044
+ }
2045
+ function createBailianVoiceConfigTester(options = {}) {
2046
+ const ttsClientFactory = options.ttsClientFactory ?? createBailianTtsClient;
2047
+ const asrClientFactory = options.asrClientFactory ?? createBailianAsrClient;
2048
+ const sampleText = options.sampleText ?? "\u8BED\u97F3\u52A9\u624B\u6D4B\u8BD5";
2049
+ const timeoutMs = options.timeoutMs ?? 8e3;
2050
+ return {
2051
+ async test(config) {
2052
+ if (!config.apiKey) {
2053
+ return Promise.reject(new Error("\u8BF7\u5148\u586B\u5199\u963F\u91CC\u4E91\u767E\u70BC API Key"));
2054
+ }
2055
+ const audio = await synthesizeTestAudio({
2056
+ config,
2057
+ sampleText,
2058
+ timeoutMs,
2059
+ clientFactory: ttsClientFactory
2060
+ });
2061
+ const transcript = await recognizeTestAudio({
2062
+ config,
2063
+ audio,
2064
+ sampleText,
2065
+ timeoutMs,
2066
+ clientFactory: asrClientFactory
2067
+ });
2068
+ return { audio, sampleRate: TEST_SAMPLE_RATE, transcript };
2069
+ }
2070
+ };
2071
+ }
2072
+ function synthesizeTestAudio(options) {
2073
+ const { config, sampleText, timeoutMs, clientFactory } = options;
2074
+ const client = clientFactory({
2075
+ apiKey: config.apiKey,
2076
+ region: config.region,
2077
+ model: config.ttsModel,
2078
+ voice: config.ttsVoice,
2079
+ sampleRate: TEST_SAMPLE_RATE
2080
+ });
2081
+ return new Promise((resolve2, reject) => {
2082
+ let settled = false;
2083
+ const chunks = [];
2084
+ const timer = setTimeout(() => {
2085
+ settle(new Error("TTS \u6D4B\u8BD5\u8D85\u65F6"));
2086
+ }, timeoutMs);
2087
+ function settle(error, audio) {
2088
+ if (settled) return;
2089
+ settled = true;
2090
+ clearTimeout(timer);
2091
+ client.close();
2092
+ if (error) reject(error);
2093
+ else resolve2(audio ?? Buffer.alloc(0));
2094
+ }
2095
+ client.on("audio", (chunk) => {
2096
+ if (chunk.length > 0) chunks.push(chunk);
2097
+ });
2098
+ client.on("finished", () => {
2099
+ if (chunks.length === 0) {
2100
+ settle(new Error("TTS \u6D4B\u8BD5\u6CA1\u6709\u8FD4\u56DE\u97F3\u9891"));
2101
+ return;
2102
+ }
2103
+ settle(void 0, Buffer.concat(chunks));
2104
+ });
2105
+ client.on("error", (error) => settle(error));
2106
+ client.on("closed", () => {
2107
+ if (!settled) settle(new Error("TTS \u6D4B\u8BD5\u8FDE\u63A5\u5DF2\u5173\u95ED"));
2108
+ });
2109
+ try {
2110
+ client.speak(sampleText);
2111
+ } catch (err) {
2112
+ settle(err instanceof Error ? err : new Error("TTS \u6D4B\u8BD5\u542F\u52A8\u5931\u8D25"));
2113
+ }
2114
+ });
2115
+ }
2116
+ function recognizeTestAudio(options) {
2117
+ const { config, audio, sampleText, timeoutMs, clientFactory } = options;
2118
+ const client = clientFactory({
2119
+ apiKey: config.apiKey,
2120
+ region: config.region,
2121
+ model: config.asrModel,
2122
+ sampleRate: TEST_SAMPLE_RATE,
2123
+ language: "zh"
2124
+ });
2125
+ return new Promise((resolve2, reject) => {
2126
+ let settled = false;
2127
+ let streamTimer = null;
2128
+ const timer = setTimeout(() => {
2129
+ settle(new Error("STT \u6D4B\u8BD5\u8D85\u65F6"));
2130
+ }, timeoutMs);
2131
+ function settle(error, transcript) {
2132
+ if (settled) return;
2133
+ settled = true;
2134
+ clearTimeout(timer);
2135
+ if (streamTimer) clearTimeout(streamTimer);
2136
+ client.close();
2137
+ if (error) reject(error);
2138
+ else resolve2(transcript ?? "");
2139
+ }
2140
+ client.on("ready", () => {
2141
+ let offset = 0;
2142
+ const sendNextChunk = () => {
2143
+ if (settled) return;
2144
+ const chunk = audio.subarray(offset, offset + ASR_TEST_CHUNK_BYTES);
2145
+ if (chunk.length > 0) {
2146
+ client.sendPcm(chunk);
2147
+ offset += chunk.length;
2148
+ }
2149
+ if (offset >= audio.length) {
2150
+ client.stop();
2151
+ return;
2152
+ }
2153
+ streamTimer = setTimeout(sendNextChunk, ASR_TEST_CHUNK_INTERVAL_MS);
2154
+ };
2155
+ sendNextChunk();
2156
+ });
2157
+ client.on("final", (transcript) => {
2158
+ if (matchesExpectedTranscript(transcript, sampleText)) {
2159
+ settle(void 0, transcript);
2160
+ return;
2161
+ }
2162
+ settle(new Error(`STT \u6D4B\u8BD5\u8BC6\u522B\u7ED3\u679C\u4E0D\u5339\u914D\uFF1A${transcript}`));
2163
+ });
2164
+ client.on("error", (error) => settle(error));
2165
+ client.on("closed", () => {
2166
+ if (!settled) settle(new Error("STT \u6D4B\u8BD5\u8FDE\u63A5\u5DF2\u5173\u95ED"));
2167
+ });
2168
+ });
2169
+ }
2170
+ function matchesExpectedTranscript(actual, expected) {
2171
+ return normalizeTranscript(actual).includes(normalizeTranscript(expected));
2172
+ }
2173
+ function normalizeTranscript(text) {
2174
+ return text.replace(/[^\p{Script=Han}a-zA-Z0-9]/gu, "").toLowerCase();
2175
+ }
2176
+
2177
+ // src/voice/client-controls.ts
2178
+ function handleVoiceConfigControl(msg, ws, store, logger, providers) {
2179
+ if (msg.type === "voice_config_request") {
2180
+ ws.send(
2181
+ JSON.stringify({
2182
+ type: "voice_config_response",
2183
+ requestId: msg.requestId,
2184
+ config: store.read()
2185
+ })
2186
+ );
2187
+ return true;
2188
+ }
2189
+ if (msg.type === "voice_config_update") {
2190
+ try {
2191
+ const config = store.update(msg.config);
2192
+ ws.send(
2193
+ JSON.stringify({
2194
+ type: "voice_config_update_response",
2195
+ requestId: msg.requestId,
2196
+ success: true,
2197
+ config
2198
+ })
2199
+ );
2200
+ } catch (err) {
2201
+ logger.warn({ err }, "Voice config update failed");
2202
+ ws.send(
2203
+ JSON.stringify({
2204
+ type: "voice_config_update_response",
2205
+ requestId: msg.requestId,
2206
+ success: false,
2207
+ errorCode: ControlErrorCode.UNKNOWN,
2208
+ error: err instanceof Error ? err.message : "Voice config update failed"
2209
+ })
2210
+ );
2211
+ }
2212
+ return true;
2213
+ }
2214
+ if (msg.type === "voice_capabilities_request") {
2215
+ if (!providers) {
2216
+ ws.send(
2217
+ JSON.stringify({
2218
+ type: "voice_capabilities_response",
2219
+ requestId: msg.requestId,
2220
+ errorCode: ControlErrorCode.UNKNOWN,
2221
+ error: "Voice capabilities provider is not available"
2222
+ })
2223
+ );
2224
+ return true;
2225
+ }
2226
+ const config = { ...store.readSecret(), ...msg.region ? { region: msg.region } : {} };
2227
+ let provider;
2228
+ try {
2229
+ provider = providers.current(config);
2230
+ } catch (err) {
2231
+ logger.warn({ err }, "Voice capabilities provider resolution failed");
2232
+ ws.send(
2233
+ JSON.stringify({
2234
+ type: "voice_capabilities_response",
2235
+ requestId: msg.requestId,
2236
+ errorCode: ControlErrorCode.UNKNOWN,
2237
+ error: err instanceof Error ? err.message : "Voice capabilities request failed"
2238
+ })
2239
+ );
2240
+ return true;
2241
+ }
2242
+ void provider.readCapabilities(config).then((capabilities) => {
2243
+ ws.send(
2244
+ JSON.stringify({
2245
+ type: "voice_capabilities_response",
2246
+ requestId: msg.requestId,
2247
+ capabilities
2248
+ })
2249
+ );
2250
+ }).catch((err) => {
2251
+ logger.warn({ err }, "Voice capabilities request failed");
2252
+ ws.send(
2253
+ JSON.stringify({
2254
+ type: "voice_capabilities_response",
2255
+ requestId: msg.requestId,
2256
+ errorCode: ControlErrorCode.UNKNOWN,
2257
+ error: err instanceof Error ? err.message : "Voice capabilities request failed"
2258
+ })
2259
+ );
2260
+ });
2261
+ return true;
2262
+ }
2263
+ if (msg.type === "voice_config_test") {
2264
+ if (!providers) {
2265
+ ws.send(
2266
+ JSON.stringify({
2267
+ type: "voice_config_test_response",
2268
+ requestId: msg.requestId,
2269
+ success: false,
2270
+ errorCode: ControlErrorCode.UNKNOWN,
2271
+ error: "Voice config tester is not available"
2272
+ })
2273
+ );
2274
+ return true;
2275
+ }
2276
+ const testConfig = mergeVoiceConfigForTest(store.readSecret(), msg.config);
2277
+ let provider;
2278
+ try {
2279
+ provider = providers.current(testConfig);
2280
+ } catch (err) {
2281
+ logger.warn({ err }, "Voice config test provider resolution failed");
2282
+ ws.send(
2283
+ JSON.stringify({
2284
+ type: "voice_config_test_response",
2285
+ requestId: msg.requestId,
2286
+ success: false,
2287
+ errorCode: ControlErrorCode.UNKNOWN,
2288
+ error: err instanceof Error ? err.message : "Voice config test failed"
2289
+ })
2290
+ );
2291
+ return true;
2292
+ }
2293
+ void provider.testConfig(testConfig).then((result) => {
2294
+ ws.send(
2295
+ JSON.stringify({
2296
+ type: "voice_config_test_response",
2297
+ requestId: msg.requestId,
2298
+ success: true,
2299
+ ...result.audio ? { audioBase64: result.audio.toString("base64") } : {},
2300
+ ...result.sampleRate ? { audioSampleRate: result.sampleRate } : {},
2301
+ ...result.audio ? { audioEncoding: "pcm_s16le" } : {},
2302
+ ...result.transcript ? { transcript: result.transcript } : {}
2303
+ })
2304
+ );
2305
+ }).catch((err) => {
2306
+ logger.warn({ err }, "Voice config test failed");
2307
+ ws.send(
2308
+ JSON.stringify({
2309
+ type: "voice_config_test_response",
2310
+ requestId: msg.requestId,
2311
+ success: false,
2312
+ errorCode: ControlErrorCode.UNKNOWN,
2313
+ error: err instanceof Error ? err.message : "Voice config test failed"
2314
+ })
2315
+ );
2316
+ });
2317
+ return true;
2318
+ }
2319
+ return false;
2320
+ }
2321
+
2322
+ // src/handlers/client.ts
2323
+ var MAX_JSON_MESSAGE_SIZE2 = 1 * 1024 * 1024;
2324
+ function handleClientRegister(clientId, clientWs, registry, logger) {
2325
+ clientWs.clientId = clientId;
2326
+ const binding = registry.getClientBinding(clientId);
2327
+ if (!binding) {
2328
+ clientWs.send(
2329
+ JSON.stringify({
2330
+ type: "client_register_response",
2331
+ status: "new"
2332
+ })
2333
+ );
2334
+ logger.info({ clientId, status: "new" }, "Client registered");
2335
+ return;
2336
+ }
2337
+ const { proxyId } = binding;
2338
+ registry.updateClientSocket(clientId, clientWs);
2339
+ clientWs.boundProxyId = proxyId;
2340
+ if (!registry.isProxyOnline(proxyId)) {
2341
+ clientWs.send(
2342
+ JSON.stringify({
2343
+ type: "client_register_response",
2344
+ status: "proxy_offline",
2345
+ proxyId
2346
+ })
2347
+ );
2348
+ logger.info({ clientId, proxyId, status: "proxy_offline" }, "Client registered");
2349
+ return;
2350
+ }
2351
+ clientWs.send(
2352
+ JSON.stringify({
2353
+ type: "client_register_response",
2354
+ status: "restored",
2355
+ proxyId
2356
+ })
2357
+ );
2358
+ logger.info({ clientId, proxyId, status: "restored" }, "Client registered");
2359
+ }
2360
+ function rejectNotBound(ws) {
2361
+ ws.send(
2362
+ JSON.stringify({
2363
+ type: "relay_error",
2364
+ code: RelayErrorCode.NOT_BOUND,
2365
+ message: "Client is not bound to any proxy"
2366
+ })
2367
+ );
2368
+ }
2369
+ function rejectProxySelect(ws, requestId, proxyId) {
2370
+ ws.send(
2371
+ JSON.stringify({
2372
+ type: "proxy_select_response",
2373
+ requestId,
2374
+ success: false,
2375
+ errorCode: ControlErrorCode.PROXY_OFFLINE,
2376
+ error: `Proxy not online: ${proxyId}`
2377
+ })
2378
+ );
2379
+ }
2380
+ function sendRelayProxyProbeFailure(ws, requestId, error, chaos) {
2381
+ const response = serializeControl({
2382
+ type: "latency_relay_proxy_response",
2383
+ requestId,
2384
+ success: false,
2385
+ error
2386
+ });
2387
+ if (chaos) {
2388
+ chaos.send(ws, response, {
2389
+ direction: "proxy_to_client",
2390
+ type: "latency_relay_proxy_response"
2391
+ });
2392
+ return;
2393
+ }
2394
+ ws.send(response);
2395
+ }
2396
+ function handleClientConnection(ws, registry, logger, chaos, voiceConfigStore, voiceProviders) {
2397
+ const clientWs = ws;
2398
+ clientWs.isAlive = true;
2399
+ registry.addClientWs(clientWs);
2400
+ clientWs.on("pong", () => {
2401
+ clientWs.isAlive = true;
2402
+ });
2403
+ clientWs.on("message", (data, isBinary) => {
2404
+ if (isBinary) {
2405
+ return;
2406
+ }
2407
+ if (data.length > MAX_JSON_MESSAGE_SIZE2) {
2408
+ logger.warn(
2409
+ { size: data.length, clientId: clientWs.clientId },
2410
+ "JSON message rejected: exceeds max size"
2411
+ );
2412
+ return;
2413
+ }
2414
+ const raw = data.toString();
2415
+ const result = parseMessage(raw);
2416
+ if (result.kind === "control") {
2417
+ const msg = result.message;
2418
+ logger.info(
2419
+ { type: msg.type, clientId: clientWs.clientId, bound: clientWs.boundProxyId },
2420
+ "Client message received"
2421
+ );
2422
+ if (msg.type === "client_register") {
2423
+ handleClientRegister(msg.clientId, clientWs, registry, logger);
2424
+ return;
2425
+ }
2426
+ if (msg.type === "proxy_list_request") {
2427
+ const proxies = registry.listProxiesWithName().map((p) => ({
2428
+ ...p,
2429
+ sessions: registry.getSessionsForProxy(p.proxyId)
2430
+ }));
2431
+ const response = JSON.stringify({
2432
+ type: "proxy_list_response",
2433
+ requestId: msg.requestId,
2434
+ proxies
2435
+ });
2436
+ if (chaos) {
2437
+ chaos.send(clientWs, response, {
2438
+ direction: "proxy_to_client",
2439
+ type: "proxy_list_response"
2440
+ });
2441
+ } else {
2442
+ clientWs.send(response);
2443
+ }
2444
+ return;
2445
+ }
2446
+ if (msg.type === "latency_web_relay_ping") {
2447
+ const response = serializeControl({
2448
+ type: "latency_web_relay_pong",
2449
+ requestId: msg.requestId,
2450
+ relayNow: Date.now()
2451
+ });
2452
+ if (chaos) {
2453
+ chaos.send(clientWs, response, {
2454
+ direction: "proxy_to_client",
2455
+ type: "latency_web_relay_pong"
2456
+ });
2457
+ } else {
2458
+ clientWs.send(response);
2459
+ }
2460
+ return;
2461
+ }
2462
+ if (msg.type === "latency_relay_proxy_request") {
2463
+ const targetProxyId = clientWs.boundProxyId;
2464
+ if (!targetProxyId) {
2465
+ sendRelayProxyProbeFailure(clientWs, msg.requestId, "\u5F53\u524D\u672A\u8FDE\u63A5\u5F00\u53D1\u673A", chaos);
2466
+ return;
2467
+ }
2468
+ const proxyWs = registry.getProxy(targetProxyId);
2469
+ if (!proxyWs || proxyWs.readyState !== WebSocket7.OPEN) {
2470
+ sendRelayProxyProbeFailure(
2471
+ clientWs,
2472
+ msg.requestId,
2473
+ `\u5F00\u53D1\u673A ${targetProxyId} \u4E0D\u5728\u7EBF`,
2474
+ chaos
2475
+ );
2476
+ return;
2477
+ }
2478
+ startRelayProxyLatencyProbe({
2479
+ requestId: msg.requestId,
2480
+ proxyId: targetProxyId,
2481
+ proxyWs,
2482
+ clientWs,
2483
+ logger,
2484
+ chaos
2485
+ });
2486
+ return;
2487
+ }
2488
+ if (voiceConfigStore && handleVoiceConfigControl(msg, clientWs, voiceConfigStore, logger, voiceProviders)) {
2489
+ return;
2490
+ }
2491
+ if (isClientToProxyRelayControlType(msg.type)) {
2492
+ const targetProxyId = clientWs.boundProxyId;
2493
+ if (!targetProxyId) {
2494
+ rejectNotBound(clientWs);
2495
+ return;
2496
+ }
2497
+ const proxyWs = registry.getProxy(targetProxyId);
2498
+ if (proxyWs && proxyWs.readyState === WebSocket7.OPEN) {
2499
+ if (chaos) chaos.send(proxyWs, raw, { direction: "client_to_proxy", type: msg.type });
2500
+ else proxyWs.send(raw);
2501
+ } else {
2502
+ clientWs.send(
2503
+ JSON.stringify({
2504
+ type: "relay_error",
2505
+ code: RelayErrorCode.PROXY_OFFLINE,
2506
+ message: `Proxy ${targetProxyId} is not available`
2507
+ })
2508
+ );
2509
+ }
2510
+ return;
2511
+ }
2512
+ if (msg.type === "proxy_select") {
2513
+ if (!registry.isProxyOnline(msg.proxyId)) {
2514
+ rejectProxySelect(clientWs, msg.requestId, msg.proxyId);
2515
+ return;
2516
+ }
2517
+ if (!clientWs.clientId) {
2518
+ clientWs.clientId = `anon-${nanoid3(10)}`;
2519
+ }
2520
+ const bound = registry.bindClientById(clientWs.clientId, msg.proxyId, clientWs);
2521
+ if (!bound) {
2522
+ rejectProxySelect(clientWs, msg.requestId, msg.proxyId);
2523
+ return;
2524
+ }
2525
+ clientWs.boundProxyId = msg.proxyId;
2526
+ const response = JSON.stringify({
2527
+ type: "proxy_select_response",
2528
+ requestId: msg.requestId,
2529
+ success: true,
2530
+ proxyId: msg.proxyId
2531
+ });
2532
+ if (chaos) {
2533
+ chaos.send(clientWs, response, {
2534
+ direction: "proxy_to_client",
2535
+ type: "proxy_select_response"
2536
+ });
2537
+ } else {
2538
+ clientWs.send(response);
2539
+ }
2540
+ logger.info({ proxyId: msg.proxyId, clientId: clientWs.clientId }, "Client bound to proxy");
2541
+ return;
2542
+ }
2543
+ clientWs.send(
2544
+ JSON.stringify({
2545
+ type: "relay_error",
2546
+ code: RelayErrorCode.UNSUPPORTED,
2547
+ message: `Unsupported control message: ${msg.type}`
2548
+ })
2549
+ );
2550
+ return;
2551
+ }
2552
+ if (result.kind === "envelope") {
2553
+ if (!clientWs.boundProxyId) {
2554
+ rejectNotBound(clientWs);
2555
+ return;
2556
+ }
2557
+ routeClientMessage(raw, clientWs.boundProxyId, clientWs, registry, logger, chaos);
2558
+ return;
2559
+ }
2560
+ logger.error({ error: result.error, raw: raw.slice(0, 200) }, "Invalid message from client");
2561
+ clientWs.send(
2562
+ JSON.stringify({
2563
+ type: "relay_error",
2564
+ code: RelayErrorCode.INVALID_MESSAGE,
2565
+ message: `${result.error} | raw: ${raw.slice(0, 200)}`
2566
+ })
2567
+ );
2568
+ });
2569
+ clientWs.on("close", () => {
2570
+ registry.removeClientWs(clientWs);
2571
+ if (clientWs.clientId) {
2572
+ registry.unbindClientById(clientWs.clientId);
2573
+ }
2574
+ logger.info({ clientId: clientWs.clientId }, "Client disconnected");
2575
+ });
2576
+ clientWs.on("error", (err) => {
2577
+ logger.error({ err }, "Client WebSocket error");
2578
+ });
2579
+ }
2580
+
2581
+ // src/heartbeat.ts
2582
+ function markAlive(ws) {
2583
+ ws.isAlive = true;
2584
+ }
2585
+ function setupHeartbeat(wss, interval = 3e4) {
2586
+ wss.on("connection", (ws) => {
2587
+ markAlive(ws);
2588
+ ws.on("pong", () => {
2589
+ markAlive(ws);
2590
+ });
2591
+ });
2592
+ return setInterval(() => {
2593
+ for (const ws of wss.clients) {
2594
+ const sock = ws;
2595
+ if (sock.isAlive === false) {
2596
+ sock.terminate();
2597
+ continue;
2598
+ }
2599
+ sock.isAlive = false;
2600
+ sock.ping();
2601
+ }
2602
+ }, interval);
2603
+ }
2604
+
2605
+ // src/chaos.ts
2606
+ import { WebSocket as WebSocket8 } from "ws";
2607
+ function parseRelayChaosFromEnv(env) {
2608
+ const enabled = env.DEV_ANYWHERE_RELAY_CHAOS === "1";
2609
+ const types = env.DEV_ANYWHERE_RELAY_CHAOS_TYPES?.split(",").map((type) => type.trim()).filter(Boolean);
2610
+ return {
2611
+ enabled,
2612
+ delayMs: parseInt(env.DEV_ANYWHERE_RELAY_CHAOS_DELAY_MS ?? "0", 10),
2613
+ duplicate: env.DEV_ANYWHERE_RELAY_CHAOS_DUPLICATE === "1",
2614
+ duplicateDelayMs: parseInt(env.DEV_ANYWHERE_RELAY_CHAOS_DUPLICATE_DELAY_MS ?? "10", 10),
2615
+ reorder: env.DEV_ANYWHERE_RELAY_CHAOS_REORDER === "1",
2616
+ reorderDelayMs: parseInt(env.DEV_ANYWHERE_RELAY_CHAOS_REORDER_DELAY_MS ?? "40", 10),
2617
+ types: types && types.length > 0 ? new Set(types) : void 0
2618
+ };
2619
+ }
2620
+ function createRelayChaos(options, logger) {
2621
+ let sequence = 0;
2622
+ function shouldAffect(meta) {
2623
+ if (!options.enabled) return false;
2624
+ if (options.types && !options.types.has(meta.type)) return false;
2625
+ return true;
2626
+ }
2627
+ function sendNow(ws, data) {
2628
+ if (ws.readyState === WebSocket8.OPEN) {
2629
+ ws.send(data);
2630
+ }
2631
+ }
2632
+ return {
2633
+ send(ws, data, meta) {
2634
+ if (!shouldAffect(meta)) {
2635
+ sendNow(ws, data);
2636
+ return;
2637
+ }
2638
+ sequence += 1;
2639
+ const reorderDelay = options.reorder && sequence % 2 === 1 ? options.reorderDelayMs : 0;
2640
+ const delayMs = Math.max(0, options.delayMs + reorderDelay);
2641
+ logger.warn(
2642
+ { direction: meta.direction, type: meta.type, delayMs, duplicate: options.duplicate },
2643
+ "Relay chaos forwarding message"
2644
+ );
2645
+ setTimeout(() => sendNow(ws, data), delayMs);
2646
+ if (options.duplicate) {
2647
+ setTimeout(() => sendNow(ws, data), delayMs + options.duplicateDelayMs);
2648
+ }
2649
+ }
2650
+ };
2651
+ }
2652
+
2653
+ // src/voice/config-store.ts
2654
+ import { existsSync, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
2655
+ import { dirname as dirname2, join as join2 } from "path";
2656
+ var DEFAULT_STORED_CONFIG = {
2657
+ provider: "aliyun-bailian",
2658
+ region: "cn",
2659
+ asrModel: "qwen3-asr-flash-realtime",
2660
+ ttsModel: "cosyvoice-v3-flash",
2661
+ ttsVoice: "longanyang",
2662
+ turnIdleSeconds: 3
2663
+ };
2664
+ function redacted(config) {
2665
+ return VoiceProviderConfigSchema.parse({
2666
+ provider: config.provider,
2667
+ configured: Boolean(config.apiKey),
2668
+ region: config.region,
2669
+ asrModel: config.asrModel,
2670
+ ttsModel: config.ttsModel,
2671
+ ttsVoice: config.ttsVoice,
2672
+ turnIdleSeconds: config.turnIdleSeconds
2673
+ });
2674
+ }
2675
+ function mergeDefaults(defaults) {
2676
+ return {
2677
+ ...DEFAULT_STORED_CONFIG,
2678
+ ...defaults
2679
+ };
2680
+ }
2681
+ function parseStoredConfig(raw, fallback) {
2682
+ if (!raw || typeof raw !== "object") return fallback;
2683
+ const candidate = raw;
2684
+ return {
2685
+ ...fallback,
2686
+ provider: "aliyun-bailian",
2687
+ ...typeof candidate.apiKey === "string" && candidate.apiKey.length > 0 ? { apiKey: candidate.apiKey } : { apiKey: void 0 },
2688
+ ...candidate.region === "cn" || candidate.region === "intl" ? { region: candidate.region } : {},
2689
+ ...typeof candidate.asrModel === "string" && candidate.asrModel.length > 0 ? { asrModel: candidate.asrModel } : {},
2690
+ ...typeof candidate.ttsModel === "string" && candidate.ttsModel.length > 0 ? { ttsModel: candidate.ttsModel } : {},
2691
+ ...typeof candidate.ttsVoice === "string" && candidate.ttsVoice.length > 0 ? { ttsVoice: candidate.ttsVoice } : {},
2692
+ ...typeof candidate.turnIdleSeconds === "number" && Number.isSafeInteger(candidate.turnIdleSeconds) && candidate.turnIdleSeconds > 0 ? { turnIdleSeconds: candidate.turnIdleSeconds } : {}
2693
+ };
2694
+ }
2695
+ function createVoiceConfigStore(options = {}) {
2696
+ const fallback = mergeDefaults(options.defaults);
2697
+ const filePath = options.dataDir ? join2(options.dataDir, "voice-config.json") : null;
2698
+ let memoryConfig = fallback;
2699
+ function load() {
2700
+ if (!filePath) return memoryConfig;
2701
+ if (!existsSync(filePath)) return fallback;
2702
+ try {
2703
+ return parseStoredConfig(JSON.parse(readFileSync2(filePath, "utf8")), fallback);
2704
+ } catch {
2705
+ return fallback;
2706
+ }
2707
+ }
2708
+ function save(config) {
2709
+ if (!filePath) {
2710
+ memoryConfig = config;
2711
+ return;
2712
+ }
2713
+ mkdirSync(dirname2(filePath), { recursive: true });
2714
+ writeFileSync(filePath, `${JSON.stringify(config, null, 2)}
2715
+ `, { mode: 384 });
2716
+ }
2717
+ return {
2718
+ read() {
2719
+ return redacted(load());
2720
+ },
2721
+ update(update) {
2722
+ const parsed = VoiceConfigUpdateSchema.parse(update);
2723
+ const current = load();
2724
+ const next = {
2725
+ ...current,
2726
+ provider: "aliyun-bailian",
2727
+ ...parsed.clearApiKey ? { apiKey: void 0 } : {},
2728
+ ...parsed.apiKey ? { apiKey: parsed.apiKey } : {},
2729
+ ...parsed.region ? { region: parsed.region } : {},
2730
+ ...parsed.asrModel ? { asrModel: parsed.asrModel } : {},
2731
+ ...parsed.ttsModel ? { ttsModel: parsed.ttsModel } : {},
2732
+ ...parsed.ttsVoice ? { ttsVoice: parsed.ttsVoice } : {},
2733
+ ...parsed.turnIdleSeconds ? { turnIdleSeconds: parsed.turnIdleSeconds } : {}
2734
+ };
2735
+ save(next);
2736
+ return redacted(next);
2737
+ },
2738
+ readSecret() {
2739
+ return load();
2740
+ }
2741
+ };
2742
+ }
2743
+
2744
+ // src/voice/asr-ws.ts
2745
+ import { WebSocket as WebSocket9 } from "ws";
2746
+ function sendJson(ws, payload) {
2747
+ if (ws.readyState === WebSocket9.OPEN) {
2748
+ ws.send(JSON.stringify(payload));
2749
+ }
2750
+ }
2751
+ function toBuffer(data) {
2752
+ if (Buffer.isBuffer(data)) return data;
2753
+ if (data instanceof ArrayBuffer) return Buffer.from(data);
2754
+ return Buffer.concat(data);
2755
+ }
2756
+ function parseJson(data) {
2757
+ try {
2758
+ return JSON.parse(toBuffer(data).toString("utf8"));
2759
+ } catch {
2760
+ return null;
2761
+ }
2762
+ }
2763
+ function isStartMessage(payload) {
2764
+ if (!payload || typeof payload !== "object") return false;
2765
+ const record = payload;
2766
+ return record.type === "start" && typeof record.sessionId === "string";
2767
+ }
2768
+ function handleVoiceAsrConnection(ws, store, logger, providers) {
2769
+ let provider = null;
2770
+ function start(payload) {
2771
+ const config = store.readSecret();
2772
+ if (!config.apiKey) {
2773
+ sendJson(ws, {
2774
+ type: "error",
2775
+ errorCode: "not_configured",
2776
+ error: "Voice provider is not configured"
2777
+ });
2778
+ return;
2779
+ }
2780
+ provider?.close();
2781
+ provider = providers.current(config).createAsrClient(config, {
2782
+ sampleRate: payload.sampleRate ?? 16e3,
2783
+ language: "zh"
2784
+ });
2785
+ provider.on("ready", () => sendJson(ws, { type: "ready" }));
2786
+ provider.on("partial", (text) => sendJson(ws, { type: "partial", text }));
2787
+ provider.on("final", (text) => sendJson(ws, { type: "final", text }));
2788
+ provider.on(
2789
+ "error",
2790
+ (error) => sendJson(ws, { type: "error", error: error.message || "ASR failed" })
2791
+ );
2792
+ provider.on("closed", (code, reason) => sendJson(ws, { type: "closed", code, reason }));
2793
+ }
2794
+ ws.on("message", (data, isBinary) => {
2795
+ if (isBinary) {
2796
+ provider?.sendPcm(toBuffer(data));
2797
+ return;
2798
+ }
2799
+ const payload = parseJson(data);
2800
+ if (isStartMessage(payload)) {
2801
+ start(payload);
2802
+ return;
2803
+ }
2804
+ if (payload && typeof payload === "object" && payload.type === "stop") {
2805
+ provider?.stop();
2806
+ }
2807
+ });
2808
+ ws.on("close", () => {
2809
+ provider?.close();
2810
+ provider = null;
2811
+ });
2812
+ ws.on("error", (err) => {
2813
+ logger.warn({ err }, "Voice ASR websocket error");
2814
+ provider?.close();
2815
+ provider = null;
2816
+ });
2817
+ }
2818
+
2819
+ // src/voice/tts-ws.ts
2820
+ import { WebSocket as WebSocket10 } from "ws";
2821
+ function sendJson2(ws, payload) {
2822
+ if (ws.readyState === WebSocket10.OPEN) {
2823
+ ws.send(JSON.stringify(payload));
2824
+ }
2825
+ }
2826
+ function parseJson2(data) {
2827
+ try {
2828
+ const buffer = Buffer.isBuffer(data) ? data : data instanceof ArrayBuffer ? Buffer.from(data) : Buffer.concat(data);
2829
+ return JSON.parse(buffer.toString("utf8"));
2830
+ } catch {
2831
+ return null;
2832
+ }
2833
+ }
2834
+ function isSpeakMessage(payload) {
2835
+ if (!payload || typeof payload !== "object") return false;
2836
+ const record = payload;
2837
+ return record.type === "speak" && typeof record.requestId === "string" && typeof record.text === "string" && record.text.length > 0;
2838
+ }
2839
+ function nowMs() {
2840
+ return Date.now();
2841
+ }
2842
+ function buildStats(requestId, text, config) {
2843
+ return {
2844
+ requestId,
2845
+ textChars: text.length,
2846
+ provider: config.provider,
2847
+ region: config.region,
2848
+ ttsModel: config.ttsModel,
2849
+ ttsVoice: config.ttsVoice,
2850
+ startedAt: nowMs(),
2851
+ firstAudioAt: null,
2852
+ audioBytes: 0,
2853
+ audioChunks: 0
2854
+ };
2855
+ }
2856
+ function statsLogFields(stats) {
2857
+ const currentTime = nowMs();
2858
+ return {
2859
+ requestId: stats.requestId,
2860
+ textChars: stats.textChars,
2861
+ provider: stats.provider,
2862
+ region: stats.region,
2863
+ ttsModel: stats.ttsModel,
2864
+ ttsVoice: stats.ttsVoice,
2865
+ audioBytes: stats.audioBytes,
2866
+ audioChunks: stats.audioChunks,
2867
+ durationMs: currentTime - stats.startedAt,
2868
+ firstAudioMs: stats.firstAudioAt === null ? null : stats.firstAudioAt - stats.startedAt
2869
+ };
2870
+ }
2871
+ function closeReasonText(reason) {
2872
+ if (Buffer.isBuffer(reason)) return reason.toString("utf8");
2873
+ return typeof reason === "string" ? reason : "";
2874
+ }
2875
+ function handleVoiceTtsConnection(ws, store, logger, providers) {
2876
+ let provider = null;
2877
+ let providerConfig = null;
2878
+ let activeRequestId = null;
2879
+ let activeStats = null;
2880
+ function ensureProvider() {
2881
+ if (provider && providerConfig) return { client: provider, config: providerConfig };
2882
+ const config = store.readSecret();
2883
+ if (!config.apiKey) {
2884
+ sendJson2(ws, {
2885
+ type: "error",
2886
+ errorCode: "not_configured",
2887
+ error: "Voice provider is not configured"
2888
+ });
2889
+ return null;
2890
+ }
2891
+ provider = providers.current(config).createTtsClient(config, {
2892
+ sampleRate: 24e3
2893
+ });
2894
+ providerConfig = config;
2895
+ provider.on("started", () => {
2896
+ if (activeStats) logger.info(statsLogFields(activeStats), "Voice TTS started");
2897
+ sendJson2(ws, { type: "started", requestId: activeRequestId });
2898
+ });
2899
+ provider.on("audio", (chunk) => {
2900
+ if (activeStats) {
2901
+ if (activeStats.firstAudioAt === null) activeStats.firstAudioAt = nowMs();
2902
+ activeStats.audioBytes += chunk.byteLength;
2903
+ activeStats.audioChunks += 1;
2904
+ }
2905
+ if (ws.readyState === WebSocket10.OPEN) ws.send(chunk);
2906
+ });
2907
+ provider.on("finished", () => {
2908
+ if (activeStats) logger.info(statsLogFields(activeStats), "Voice TTS finished");
2909
+ sendJson2(ws, { type: "finished", requestId: activeRequestId });
2910
+ activeRequestId = null;
2911
+ activeStats = null;
2912
+ });
2913
+ provider.on("error", (error) => {
2914
+ if (activeStats) {
2915
+ logger.warn(
2916
+ { ...statsLogFields(activeStats), err: error },
2917
+ "Voice TTS provider reported an error"
2918
+ );
2919
+ }
2920
+ sendJson2(ws, {
2921
+ type: "error",
2922
+ requestId: activeRequestId,
2923
+ error: error.message || "TTS failed"
2924
+ });
2925
+ activeRequestId = null;
2926
+ activeStats = null;
2927
+ });
2928
+ provider.on("closed", (code, reason) => {
2929
+ if (activeStats) {
2930
+ logger.warn(
2931
+ { ...statsLogFields(activeStats), code, reason },
2932
+ "Voice TTS provider closed before finishing"
2933
+ );
2934
+ sendJson2(ws, {
2935
+ type: "error",
2936
+ requestId: activeRequestId,
2937
+ errorCode: "provider_closed",
2938
+ error: "Voice TTS provider closed before finishing"
2939
+ });
2940
+ activeRequestId = null;
2941
+ activeStats = null;
2942
+ } else {
2943
+ logger.info({ code, reason }, "Voice TTS provider closed");
2944
+ provider = null;
2945
+ providerConfig = null;
2946
+ return;
2947
+ }
2948
+ sendJson2(ws, { type: "closed", code, reason });
2949
+ provider = null;
2950
+ providerConfig = null;
2951
+ });
2952
+ return { client: provider, config };
2953
+ }
2954
+ ws.on("message", (data) => {
2955
+ const payload = parseJson2(data);
2956
+ if (!isSpeakMessage(payload)) return;
2957
+ if (activeRequestId) {
2958
+ sendJson2(ws, {
2959
+ type: "error",
2960
+ requestId: payload.requestId,
2961
+ errorCode: "busy",
2962
+ error: "Voice TTS is already speaking"
2963
+ });
2964
+ return;
2965
+ }
2966
+ const ensured = ensureProvider();
2967
+ if (!ensured) return;
2968
+ const { client, config } = ensured;
2969
+ activeRequestId = payload.requestId;
2970
+ activeStats = buildStats(payload.requestId, payload.text, config);
2971
+ logger.info(statsLogFields(activeStats), "Voice TTS request received");
2972
+ try {
2973
+ client.speak(payload.text);
2974
+ } catch (err) {
2975
+ if (activeStats) {
2976
+ logger.warn(
2977
+ { ...statsLogFields(activeStats), err },
2978
+ "Voice TTS request failed before provider accepted it"
2979
+ );
2980
+ }
2981
+ sendJson2(ws, {
2982
+ type: "error",
2983
+ requestId: payload.requestId,
2984
+ error: err instanceof Error ? err.message : "Voice TTS failed"
2985
+ });
2986
+ activeRequestId = null;
2987
+ activeStats = null;
2988
+ }
2989
+ });
2990
+ ws.on("close", (code, reason) => {
2991
+ if (activeStats) {
2992
+ logger.warn(
2993
+ { ...statsLogFields(activeStats), code, reason: closeReasonText(reason) },
2994
+ "Voice TTS client websocket closed before finishing"
2995
+ );
2996
+ }
2997
+ const currentProvider = provider;
2998
+ provider = null;
2999
+ providerConfig = null;
3000
+ activeRequestId = null;
3001
+ activeStats = null;
3002
+ currentProvider?.close();
3003
+ });
3004
+ ws.on("error", (err) => {
3005
+ logger.warn({ err }, "Voice TTS websocket error");
3006
+ provider?.close();
3007
+ provider = null;
3008
+ providerConfig = null;
3009
+ activeRequestId = null;
3010
+ activeStats = null;
3011
+ });
3012
+ }
3013
+
3014
+ // src/voice/capabilities.ts
3015
+ var CUSTOMIZATION_ENDPOINTS = {
3016
+ cn: "https://dashscope.aliyuncs.com/api/v1/services/audio/tts/customization",
3017
+ intl: "https://dashscope-intl.aliyuncs.com/api/v1/services/audio/tts/customization"
3018
+ };
3019
+ function modelFromCustomCosyVoiceId(voiceId) {
3020
+ const match = voiceId.match(/^(cosyvoice-v3(?:\.5)?-(?:flash|plus))-/);
3021
+ return match?.[1];
3022
+ }
3023
+ async function fetchCustomVoices(fetchImpl, config) {
3024
+ if (!config.apiKey) return [];
3025
+ const response = await fetchImpl(CUSTOMIZATION_ENDPOINTS[config.region], {
3026
+ method: "POST",
3027
+ headers: {
3028
+ Authorization: `Bearer ${config.apiKey}`,
3029
+ "Content-Type": "application/json"
3030
+ },
3031
+ body: JSON.stringify({
3032
+ model: "voice-enrollment",
3033
+ input: {
3034
+ action: "list_voice",
3035
+ page_size: 100,
3036
+ page_index: 0
3037
+ }
3038
+ })
3039
+ });
3040
+ if (!response.ok) return [];
3041
+ const payload = response.json();
3042
+ const voiceList = (await payload).output?.voice_list ?? [];
3043
+ return voiceList.filter((voice) => !voice.status || voice.status === "OK").flatMap((voice) => {
3044
+ if (!voice.voice_id) return [];
3045
+ const model = modelFromCustomCosyVoiceId(voice.voice_id);
3046
+ return [
3047
+ {
3048
+ value: voice.voice_id,
3049
+ label: [voice.voice_id, "\u81EA\u5B9A\u4E49", voice.voice_prompt].filter(Boolean).join(" \xB7 "),
3050
+ ...model ? { model } : {},
3051
+ source: "custom"
3052
+ }
3053
+ ];
3054
+ });
3055
+ }
3056
+ function createBailianVoiceCapabilitiesProvider(options = {}) {
3057
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
3058
+ const now = options.now ?? Date.now;
3059
+ return {
3060
+ async read(config) {
3061
+ let customVoices;
3062
+ try {
3063
+ customVoices = await fetchCustomVoices(fetchImpl, config);
3064
+ } catch {
3065
+ customVoices = [];
3066
+ }
3067
+ const bundled = createBundledBailianVoiceCapabilities(now());
3068
+ return {
3069
+ ...bundled,
3070
+ ttsVoices: [...bundled.ttsVoices, ...customVoices]
3071
+ };
3072
+ }
3073
+ };
3074
+ }
3075
+
3076
+ // src/voice/bailian-provider.ts
3077
+ function requireApiKey(config) {
3078
+ if (!config.apiKey) throw new Error("Voice provider is not configured");
3079
+ return config.apiKey;
3080
+ }
3081
+ function createBailianVoiceProvider(options = {}) {
3082
+ const asrClientFactory = options.asrClientFactory ?? createBailianAsrClient;
3083
+ const ttsClientFactory = options.ttsClientFactory ?? createBailianTtsClient;
3084
+ const capabilitiesProvider = options.capabilitiesProvider ?? createBailianVoiceCapabilitiesProvider();
3085
+ const configTester = options.configTester ?? createBailianVoiceConfigTester({
3086
+ asrClientFactory,
3087
+ ttsClientFactory
3088
+ });
3089
+ return {
3090
+ id: "aliyun-bailian",
3091
+ createAsrClient(config, clientOptions) {
3092
+ return asrClientFactory({
3093
+ apiKey: requireApiKey(config),
3094
+ region: config.region,
3095
+ model: config.asrModel,
3096
+ sampleRate: clientOptions.sampleRate,
3097
+ language: clientOptions.language
3098
+ });
3099
+ },
3100
+ createTtsClient(config, clientOptions) {
3101
+ return ttsClientFactory({
3102
+ apiKey: requireApiKey(config),
3103
+ region: config.region,
3104
+ model: config.ttsModel,
3105
+ voice: config.ttsVoice,
3106
+ sampleRate: clientOptions.sampleRate
3107
+ });
3108
+ },
3109
+ readCapabilities(config) {
3110
+ return capabilitiesProvider.read(config);
3111
+ },
3112
+ testConfig(config) {
3113
+ return configTester.test(config);
3114
+ }
3115
+ };
3116
+ }
3117
+
3118
+ // src/voice/provider.ts
3119
+ function createVoiceProviderRegistry(adapters) {
3120
+ const byId = /* @__PURE__ */ new Map();
3121
+ for (const adapter of adapters) {
3122
+ if (byId.has(adapter.id)) {
3123
+ throw new Error(`Duplicate voice provider: ${adapter.id}`);
3124
+ }
3125
+ byId.set(adapter.id, adapter);
3126
+ }
3127
+ function require2(providerId) {
3128
+ const adapter = byId.get(providerId);
3129
+ if (!adapter) throw new Error(`Unsupported voice provider: ${providerId}`);
3130
+ return adapter;
3131
+ }
3132
+ return {
3133
+ current(config) {
3134
+ return require2(config.provider);
3135
+ },
3136
+ require: require2
3137
+ };
3138
+ }
3139
+
3140
+ // src/server.ts
3141
+ var MODULE_DIR = dirname3(fileURLToPath2(import.meta.url));
3142
+ var PACKAGED_FONTS_DIR = resolve(MODULE_DIR, "../assets/fonts");
3143
+ function createRelayServer(options) {
3144
+ const { heartbeatInterval = 3e4, logger, dataDir, proxyToken, clientToken, chaos } = options;
3145
+ const proxyTokenRequired = typeof proxyToken === "string" && proxyToken.length > 0;
3146
+ const clientTokenRequired = typeof clientToken === "string" && clientToken.length > 0;
3147
+ const allowedOriginsSet = options.allowedOrigins && options.allowedOrigins.length > 0 ? new Set(options.allowedOrigins) : null;
3148
+ const checkOrigin = (origin) => {
3149
+ if (!allowedOriginsSet) return true;
3150
+ return typeof origin === "string" && allowedOriginsSet.has(origin);
3151
+ };
3152
+ if (!proxyTokenRequired) {
3153
+ logger.warn(
3154
+ "proxy auth token not set, /proxy endpoint is open \u2014 ok for dev, not for public relay"
3155
+ );
3156
+ }
3157
+ if (!clientTokenRequired) {
3158
+ logger.warn(
3159
+ "client auth token not set, /client endpoint is open \u2014 ok for dev, not for public relay"
3160
+ );
3161
+ }
3162
+ const registry = new RelayRegistry();
3163
+ const voiceConfigStore = createVoiceConfigStore({
3164
+ dataDir,
3165
+ defaults: options.voiceDefaults
3166
+ });
3167
+ const voiceProviders = options.voiceProviderRegistry ?? createVoiceProviderRegistry([
3168
+ createBailianVoiceProvider({
3169
+ asrClientFactory: options.voiceAsrClientFactory,
3170
+ ttsClientFactory: options.voiceTtsClientFactory,
3171
+ capabilitiesProvider: options.voiceCapabilitiesProvider,
3172
+ configTester: options.voiceConfigTester
3173
+ })
3174
+ ]);
3175
+ const relayChaos = chaos?.enabled ? createRelayChaos(chaos, logger) : void 0;
3176
+ if (chaos?.enabled) {
3177
+ logger.warn(
3178
+ {
3179
+ delayMs: chaos.delayMs,
3180
+ duplicate: chaos.duplicate,
3181
+ reorder: chaos.reorder,
3182
+ types: chaos.types ? [...chaos.types] : "all"
3183
+ },
3184
+ "Relay chaos mode enabled"
3185
+ );
3186
+ }
3187
+ const app = express();
3188
+ const fontsDir = dataDir ? `${dataDir}/fonts` : `${homedir()}/.dev-anywhere/relay-data/fonts`;
3189
+ const fontAssetDir = options.fontAssetDir ?? PACKAGED_FONTS_DIR;
3190
+ app.use(
3191
+ "/fonts",
3192
+ (req, res, next) => {
3193
+ res.setHeader("Access-Control-Allow-Origin", "*");
3194
+ next();
3195
+ },
3196
+ express.static(fontsDir, {
3197
+ maxAge: "30d",
3198
+ immutable: true
3199
+ })
3200
+ );
3201
+ if (existsSync2(fontAssetDir)) {
3202
+ app.use(
3203
+ "/fonts",
3204
+ express.static(fontAssetDir, {
3205
+ maxAge: "30d",
3206
+ immutable: true
3207
+ })
3208
+ );
3209
+ }
3210
+ app.use(
3211
+ healthRouter(registry, {
3212
+ proxyTokenRequired,
3213
+ clientTokenRequired,
3214
+ validateClientToken: (token) => token === clientToken,
3215
+ validateProxyToken: (token) => token === proxyToken,
3216
+ getClientToken: () => clientToken ?? null
3217
+ })
3218
+ );
3219
+ const httpServer = createServer(app);
3220
+ const proxyWss = new WebSocketServer({ noServer: true });
3221
+ const clientWss = new WebSocketServer({ noServer: true });
3222
+ const voiceAsrWss = new WebSocketServer({ noServer: true });
3223
+ const voiceTtsWss = new WebSocketServer({ noServer: true });
3224
+ httpServer.on("upgrade", (request, socket, head) => {
3225
+ const url = new URL(request.url ?? "/", "http://localhost");
3226
+ const { pathname } = url;
3227
+ const origin = request.headers.origin;
3228
+ if (!checkOrigin(origin)) {
3229
+ logger.warn(
3230
+ { ip: request.socket.remoteAddress, origin: origin ?? "(missing)", pathname },
3231
+ "rejected upgrade: origin not in allowedOrigins"
3232
+ );
3233
+ socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
3234
+ socket.destroy();
3235
+ return;
3236
+ }
3237
+ if (pathname === "/proxy") {
3238
+ if (proxyTokenRequired) {
3239
+ const token = url.searchParams.get("token");
3240
+ if (token !== proxyToken) {
3241
+ logger.warn(
3242
+ { ip: request.socket.remoteAddress },
3243
+ "rejected /proxy upgrade: invalid token"
3244
+ );
3245
+ socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
3246
+ socket.destroy();
3247
+ return;
3248
+ }
3249
+ }
3250
+ proxyWss.handleUpgrade(request, socket, head, (ws) => {
3251
+ proxyWss.emit("connection", ws, request);
3252
+ });
3253
+ return;
3254
+ }
3255
+ if (pathname === "/client" || pathname === "/voice/asr" || pathname === "/voice/tts") {
3256
+ if (clientTokenRequired) {
3257
+ const token = url.searchParams.get("token");
3258
+ if (token !== clientToken) {
3259
+ logger.warn(
3260
+ { ip: request.socket.remoteAddress, pathname },
3261
+ "rejected client-side upgrade: invalid token"
3262
+ );
3263
+ socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
3264
+ socket.destroy();
3265
+ return;
3266
+ }
3267
+ }
3268
+ if (pathname === "/client") {
3269
+ clientWss.handleUpgrade(request, socket, head, (ws) => {
3270
+ clientWss.emit("connection", ws, request);
3271
+ });
3272
+ } else if (pathname === "/voice/asr") {
3273
+ voiceAsrWss.handleUpgrade(request, socket, head, (ws) => {
3274
+ voiceAsrWss.emit("connection", ws, request);
3275
+ });
3276
+ } else {
3277
+ voiceTtsWss.handleUpgrade(request, socket, head, (ws) => {
3278
+ voiceTtsWss.emit("connection", ws, request);
3279
+ });
3280
+ }
3281
+ return;
3282
+ }
3283
+ socket.destroy();
3284
+ });
3285
+ proxyWss.on("connection", (ws) => {
3286
+ handleProxyConnection(ws, registry, logger, relayChaos);
3287
+ });
3288
+ clientWss.on("connection", (ws) => {
3289
+ handleClientConnection(ws, registry, logger, relayChaos, voiceConfigStore, voiceProviders);
3290
+ });
3291
+ voiceAsrWss.on("connection", (ws) => {
3292
+ handleVoiceAsrConnection(ws, voiceConfigStore, logger, voiceProviders);
3293
+ });
3294
+ voiceTtsWss.on("connection", (ws) => {
3295
+ handleVoiceTtsConnection(ws, voiceConfigStore, logger, voiceProviders);
3296
+ });
3297
+ const proxyHeartbeat = setupHeartbeat(proxyWss, heartbeatInterval);
3298
+ const clientHeartbeat = setupHeartbeat(clientWss, heartbeatInterval);
3299
+ const voiceAsrHeartbeat = setupHeartbeat(voiceAsrWss, heartbeatInterval);
3300
+ const voiceTtsHeartbeat = setupHeartbeat(voiceTtsWss, heartbeatInterval);
3301
+ async function close() {
3302
+ clearInterval(proxyHeartbeat);
3303
+ clearInterval(clientHeartbeat);
3304
+ clearInterval(voiceAsrHeartbeat);
3305
+ clearInterval(voiceTtsHeartbeat);
3306
+ for (const ws of proxyWss.clients) {
3307
+ ws.terminate();
3308
+ }
3309
+ for (const ws of clientWss.clients) {
3310
+ ws.terminate();
3311
+ }
3312
+ for (const ws of voiceAsrWss.clients) {
3313
+ ws.terminate();
3314
+ }
3315
+ for (const ws of voiceTtsWss.clients) {
3316
+ ws.terminate();
3317
+ }
3318
+ await Promise.all([
3319
+ new Promise((resolve2, reject) => {
3320
+ proxyWss.close((err) => {
3321
+ if (err) reject(err);
3322
+ else resolve2();
3323
+ });
3324
+ }),
3325
+ new Promise((resolve2, reject) => {
3326
+ clientWss.close((err) => {
3327
+ if (err) reject(err);
3328
+ else resolve2();
3329
+ });
3330
+ }),
3331
+ new Promise((resolve2, reject) => {
3332
+ voiceAsrWss.close((err) => {
3333
+ if (err) reject(err);
3334
+ else resolve2();
3335
+ });
3336
+ }),
3337
+ new Promise((resolve2, reject) => {
3338
+ voiceTtsWss.close((err) => {
3339
+ if (err) reject(err);
3340
+ else resolve2();
3341
+ });
3342
+ })
3343
+ ]);
3344
+ await new Promise((resolve2, reject) => {
3345
+ httpServer.close((err) => {
3346
+ if (err) reject(err);
3347
+ else resolve2();
3348
+ });
3349
+ });
3350
+ }
3351
+ return { httpServer, registry, close };
3352
+ }
3353
+
3354
+ export {
3355
+ RELAY_VERSION,
3356
+ parseRelayChaosFromEnv,
3357
+ createRelayServer
3358
+ };
3359
+ //# sourceMappingURL=chunk-OJABHE5C.js.map